Field validation
Per-field validation rules: declare constraints on a field so writes that
violate them are rejected 422 on every write surface (REST, GraphQL, SDK,
MCP, batch/bulk) — not just in the admin form. Rules live in the collection
schema (the collections.fields JSON), so there is no migration and no
permission/role wiring; they apply to every writer, admins included.
Validation is enforced in the shared item-write path (validateValue +
enforceValidationRules), the same place the older required / unique flags
and field conditions run. It is an application-layer check
— it does not add a DB CHECK constraint, so it fires through the API, not on
raw DB writes.
Shape
Each field may carry a validation object. Every key is optional; only the ones
that make sense for the field’s type are honoured (a mismatched key is rejected
at collection create/patch).
{ "name": "email", "type": "text", "validation": { "format": "email", "maxLength": 254, "message": "Please enter a valid email address" }}Constraints by type
| Field type | Keys |
|---|---|
text / longtext / hash | minLength, maxLength, format ("email" | "url"), regex |
integer / number | min, max, integer (whole-number, number only) |
timestamp | minDate, maxDate |
relation_many | minSelect, maxSelect (array length) |
| any type | message, rule (cross-field) |
Notes:
formatapplies a canonical built-in pattern so you don’t hand-write it. Combined withregex, both must pass. (hashvalidates the plaintext the caller typed, before it’s hashed — so you can enforce a password policy.)minDate/maxDateaccept an epoch-ms number, an ISO-8601 string, the literal"$now", or a relative-now object using the same dynamic-date vocabulary as the filter DSL:{ "$now": { "sub": { "days": 1 } } }. The incoming value is parsed to epoch-ms before comparison. (Timestamp values are written as epoch-ms — an ISO string is not stored correctly on SQLite.)minSelect/maxSelectbound how many ids arelation_manyarray may hold.maxSelect: 1effectively makes it single-select.messageoverrides the generated error text for any failure on that field (per-value or the cross-fieldrule).- Empty values (
null/undefined/"") skip per-value validation — use therequiredflag (or a conditional required) to force a value.
Cross-field rules (rule)
validation.rule is an escape hatch for constraints that depend on other
fields in the same row. It is a filter over the whole row (same DSL as
permissions and conditions) that must match for the write to succeed.
Reference a sibling field’s value with "$field.<name>":
{ "name": "end_date", "type": "timestamp", "validation": { "rule": { "end_date": { "_gte": "$field.start_date" } }, "message": "End date must be on or after start date" }}- Evaluated in JS on every create/update against the fully-resolved row (on update, the merged before + patch row — so a rule referencing a field the PATCH omits is still judged against the stored value).
$user.id/$user.email/$user.roles/$nowresolve as they do in permission rules;$field.<name>is unique to validation rules.- Field references (both the compared field and any
$field.*) are checked at collection create/patch — an unknown field is rejected422. - Ordered comparisons (
_gt,_gte,_lt,_lte,_between) sort dates by instant, so_gteworks on timestamp fields. A rule is only meaningful when the referenced fields are present.
Admin UI
The Add field and Edit field dialogs render a Validation panel with
the type-appropriate inputs (length / bounds / format / date bounds /
cardinality), a Custom error message box, and an Advanced — cross-field
rule section that builds the rule visually. In the rule builder, the value
autocomplete offers each sibling field as $field.<name>.
Related
- Field conditions — conditional
required/readonly/hiddenfrom other fields. - Hashed fields — write-only secrets; validation applies to the plaintext.
- Permissions — the filter DSL the
ruleescape hatch reuses.