Validation¶
Validation in Layer is always explicit — it never runs automatically during load(). This separation lets you load config quickly and run only the checks that are relevant to the current context.
Categories¶
Validators are grouped by named categories. When you call validate(), you choose which categories to run. Bare (uncategorized) validators always run regardless of what categories you request.
@layerclass
class DBConfig:
host: str = field(str, default="localhost")
# Bare — runs on every validate() call
port: int = field(int, is_port, default=5432)
# Only checked when "production_cluster" is requested
ssl_cert: str = field(str, default=None, production_cluster=[require, path_exists])
password: str = field(str, default=None, production_cluster=[require], secret=True)
pipeline.validate(["production_cluster"]).raise_if_invalid() # cluster rules + bare
pipeline.validate(["local_dev"]).raise_if_invalid() # dev rules + bare
pipeline.validate([]).raise_if_invalid() # bare only
pipeline.validate("*").raise_if_invalid() # every category + bare
To validate specific fields rather than the whole schema — useful in hot-reload callbacks or after a set() call — pass a fields list:
Built-in Single-Field Validators¶
from layer import (
require, optional, not_empty, one_of, in_range, is_port, is_url,
is_positive, regex, min_length, max_length, path_exists, instance_of, each_item,
)
| Validator | What it checks |
|---|---|
require |
value is not None |
not_empty |
not None, "", [], or {} |
optional |
always passes — documents that None is intentional |
one_of("a", "b") |
value is one of the given choices |
in_range(lo, hi) |
numeric value within [lo, hi] |
is_port |
integer in 1–65535 |
is_url |
starts with http:// or https:// |
is_positive |
numeric > 0 |
regex(pattern) |
string matches the regex |
min_length(n) |
string/list length >= n |
max_length(n) |
string/list length <= n |
path_exists |
path exists on the filesystem |
instance_of(T) |
isinstance(value, T) |
each_item(validator) |
applies any validator to every item in a list |
each_item example¶
@layerclass
class Config:
allowed_schemes: list = field(
list,
each_item(one_of("http", "https", "grpc")),
default=[]
)
Built-in Cross-Field Validators¶
These validators inspect other fields on the config to enforce relational constraints.
| Validator | When it raises |
|---|---|
requires_if("other", value) |
this field is None when other == value |
requires_any("a", "b", ...) |
all listed fields are None |
requires_all("a", "b", ...) |
some but not all listed fields are set |
mutually_exclusive("a", "b", ...) |
more than one listed field is set |
depends_on("a", "b", ...) |
this field is set but a dependency is None |
@layerclass
class AuthConfig:
# At least one auth method must be configured
api_key: str = field(str, default=None, auth=[requires_any("api_key", "client_cert")])
# client_cert requires client_key to also be set
client_cert: str = field(str, default=None, auth=[depends_on("client_key")])
client_key: str = field(str, default=None)
# Cannot use both auth methods simultaneously
auth_mode: str = field(str, default=None, auth=[mutually_exclusive("api_key", "client_cert")])
Custom Validators¶
Any callable with the signature (value, field_name, config) -> True | raise ValidationError is a valid validator. The config argument gives you access to the full config object, so custom validators can also do cross-field checks.
from layer import ValidationError
def no_localhost(value, field_name, config):
"""Reject localhost in external-facing endpoints."""
if value and "localhost" in value:
raise ValidationError(
field_name,
"localhost is not allowed for external endpoints",
"no_localhost",
"external_api",
)
return True
def must_exceed(other_field):
"""Value must be strictly greater than another field's value."""
def _check(value, field_name, config):
other = getattr(config, other_field, None)
if value is not None and other is not None and value <= other:
raise ValidationError(
field_name,
f"Must be greater than '{other_field}' ({other})",
"must_exceed",
"unknown",
)
return True
return _check
def consistent_tls(value, field_name, config):
"""Both cert and key must be set together, or neither."""
has_cert = bool(getattr(config, "tls_cert", None))
has_key = bool(getattr(config, "tls_key", None))
if has_cert != has_key:
raise ValidationError(
field_name,
"tls_cert and tls_key must both be set or both be absent",
"consistent_tls",
"unknown",
)
return True
@layerclass
class ServerConfig:
endpoint: str = field(str, default=None, external=[require, is_url, no_localhost])
connect_timeout_ms: int = field(int, default=1000)
read_timeout_ms: int = field(int, default=5000, common=[must_exceed("connect_timeout_ms")])
tls_cert: str = field(str, default=None, tls=[consistent_tls])
tls_key: str = field(str, default=None)
Class-Level Validators¶
For validators that need self — stateful checks, filesystem access, or multi-field invariants — use @validator and @root_validator:
import os
from layer import validator, root_validator, ValidationError, ConfigError
@layerclass
class TLSConfig:
cert_path: str = field(str, default=None)
key_path: str = field(str, default=None)
@validator("cert_path", "key_path", categories=["secure_node"])
def _files_exist(self, field_name, value):
if value and not os.path.exists(value):
raise ValidationError(
field_name,
f"File not found: {value}",
"file_check",
"secure_node",
)
@root_validator(categories=["secure_node"])
def _cert_and_key_together(self):
if bool(self.cert_path) != bool(self.key_path):
raise ConfigError("cert_path and key_path must both be set or both be absent")
@validator runs once per listed field and receives (self, field_name, value). @root_validator runs after all field validators with only self — it's the right place for invariants that span multiple fields and can't be expressed as a single-field rule.
Both support categories=. Omit it to make the validator bare (runs on every validate() call).
Parsers¶
Parsers are transform functions that normalize a field's value after type coercion, before it is written to the field. They're distinct from validators: parsers mutate, validators assert. The separation matters because loading and validation are different concerns — sometimes you need to clean a coerced value before it's even in a state worth validating.
from layer import parser
@layerclass
class ServiceConfig:
endpoint: str = field(str, default=None, prod=[require, is_url])
tags: list = field(list, default=[])
@parser("endpoint")
def _normalize_endpoint(self, value):
"""Normalize after coercion — value is already a str here."""
if isinstance(value, str):
return value.strip().rstrip("/")
return value
@parser("endpoint", "callback_url")
def _ensure_https(self, value):
"""One parser method can cover multiple fields."""
if isinstance(value, str) and value.startswith("http://"):
return value.replace("http://", "https://", 1)
return value
If you need to transform a value before coercion — for example, stripping thousands separators from "1,234" so that int() can handle it — pass before_coerce=True:
@layerclass
class PaymentConfig:
amount_cents: int = field(int, default=0)
@parser("amount_cents", before_coerce=True)
def _clean_amount(self, value):
"""Strip currency symbols and separators before int() is called."""
if isinstance(value, str):
return value.strip().lstrip("$€£").replace(",", "")
return value
Parsers run during solidify(), solidify_env(), and set() — anywhere a value is written to a field. They receive the value and must return the transformed value.
Use parsers for: stripping whitespace, removing formatting characters (thousands separators, currency symbols), normalizing casing, expanding shorthand values, or any transformation that should be transparent to the rest of the pipeline.
Solidify Mode (Coercion Strictness)¶
SolidifyMode controls how each provider's raw data is handled when it doesn't match your schema.
| Mode | Unknown keys | Type coercion errors |
|---|---|---|
LAX |
silently ignored | swallowed, raw value kept |
STANDARD (default) |
silently ignored | raises CoercionError |
STRICT |
raises StructureError |
no coercion attempted |
from layer import ConfigPipeline, SolidifyMode
pipeline = ConfigPipeline(AppConfig, mode=SolidifyMode.STRICT)
STRICT is useful in CI or schema-validation contexts where you want to catch any drift between your config files and your schema.