Validations
A validation is a declarative rule attached to a field. data.svc evaluates every rule on
create and update before any SQL is emitted; a failure aborts the mutation and the row is
never written.
Declaring a rule
Section titled “Declaring a rule”Rules live in a field’s validations: list. Each entry is a mapping with a type: and,
depending on the type, a literal value: and/or named parameters written as sibling keys.
entities: - name: contacts fields: - name: email type: string validations: - type: required - type: email message: enter a work email address code: CONTACT_EMAIL
- name: handle type: string validations: - type: pattern name: slug - type: length min: 3 max: 32
- name: website type: string validations: - type: url protocols: [https]| Key | Meaning |
|---|---|
type | Rule selector. Unknown values are rejected at write time, not at load time. |
value | The rule’s single literal argument (minlength, maxlength, min, max, divisibleby, contains, startswith, …). |
message | Overrides the default message verbatim. |
code | Overrides the default machine code. |
| any other key | Collected into the rule’s named parameters (min, max, name, values, protocols, version, types, unit, expression). |
Two field-level shorthands expand into the same rules: maxlength: N appends a maxlength
rule and minlength: N appends a minlength rule. Both are ignored when the value is 0.
required and unique are handled specially wherever they appear — in validations: or in
modifiers: — and are hoisted onto the field’s structural flags, so modifiers: [required, unique] works as written.
When validation runs
Section titled “When validation runs”Field validation and cross-field validation both run in the BeforeValidate phase, after
default filling and the access gate. Both run before anything touches the database.
| Phase | Priority | Hook | Does |
|---|---|---|---|
| BeforeValidate | 100 | defaults | Fills defaultvalue: on create |
| BeforeValidate | 100 | access | Evaluates access rules |
| BeforeValidate | 200 | validate | Field validations: and modifiers |
| BeforeValidate | 250 | entityvalidate | Entity entityvalidations: |
| AfterValidate | 260 | filefield | Stores uploaded file parts |
| AfterValidate | 350 | businessconstraint | Multi-entity business rules |
| AfterValidate | 350 | stateflow | State transitions |
| BeforeExec | 290 | materialized | Evaluates write-time computed fields |
Consequences worth holding on to:
- Defaults are applied first, so a rule sees the defaulted value, not the absent one.
- Computed fields are materialized after validation. A rule can never see a value computed by the same write.
- File uploads are stored after validation, so
mimetypeandfilesizeinspect the inbound multipart part, not the stored reference.
The catalogue
Section titled “The catalogue”Every rule below skips silently when the value is of the wrong Go kind — string rules on a
number, numeric rules on a string. Every rule also skips when the field is absent or null;
the create-time required check is the only thing that fires on a missing value. Modifiers
are checked only when the payload carries a value for the field.
"x" in a message stands for the field name, quoted.
Presence and immutability
Section titled “Presence and immutability”| Type | Parameters | Rejects | Code | Default message |
|---|---|---|---|---|
required | — | Value missing on create | REQUIRED | field "x" is required |
required | — | Blank-or-whitespace string on create | REQUIRED | field "x" must not be empty |
required | — | Present but empty string, list, or map | REQUIRED | field "x" is required |
readonly | — | Field present in an update payload | READONLY | field "x" is read-only and cannot be modified after creation |
final | — | Field present in an update payload | FINAL | field "x" is final and cannot be modified |
writeonce | — | Field present in an update payload | WRITE_ONCE | field "x" can only be written once |
unique | — | Nothing. See below. | — | — |
readonly, final, and writeonce are modifiers: they are lifted out of validations: into
the field’s modifier list and enforced only when the update payload actually carries a value
for the field. Accepted spellings are readonly/read-only, final/immutable,
writeonce/write-once/writeonceonly, and writeonly/write-only.
String shape
Section titled “String shape”| Type | Parameters | Rejects | Code | Default message |
|---|---|---|---|---|
email | — | Not [email protected] shape | EMAIL | field "x" must be a valid email address |
regex | expression: (or value:) | String does not match the expression | PATTERN | field "x" does not match required pattern |
pattern | name: | String does not match the named pattern | PATTERN | field "x" must match named pattern <name> |
pattern | name: | Name is not one of the six below | PATTERN_UNKNOWN | field "x": unknown named pattern "<name>" |
pattern | value: | String does not match the raw expression | PATTERN | field "x" does not match pattern |
length | min: | Fewer than min runes | MIN_LENGTH | field "x" must be at least N characters |
length | max: | More than max runes | MAX_LENGTH | field "x" must be at most N characters |
length | value: alone | Rune count is not exactly value | LENGTH | field "x" must be exactly N characters |
minlength | value: | Fewer than value runes | MIN_LENGTH | field "x" must be at least N characters |
maxlength | value: | More than value runes | MAX_LENGTH | field "x" must be at most N characters |
json | — | String is not parseable JSON | JSON | field "x" must be valid JSON: <parse error> |
Length is counted in runes, so a multibyte character costs one. The exact-length form of
length applies only when the rule carries no named parameters at all.
json accepts any already-structured value without inspecting it; only strings are parsed.
Named patterns
Section titled “Named patterns”pattern with name: selects one of six built-in expressions.
| Name | Accepts |
|---|---|
alpha | A–Z, a–z, one or more |
alphanumeric | A–Z, a–z, 0–9, one or more |
numeric | 0–9, one or more |
slug | Lower-case alphanumeric groups joined by single hyphens |
hex | Hexadecimal digits, one or more |
base64 | Base64 alphabet with optional = padding |
The name is matched case-insensitively. Anything else fails the write with
PATTERN_UNKNOWN — an unknown pattern is never treated as “no rule”.
Format
Section titled “Format”| Type | Parameters | Rejects | Code | Default message |
|---|---|---|---|---|
url | — | Unparseable, or missing scheme or host | URL | field "x" must be a valid URL |
url | protocols: [..] | Scheme not in the list (case-insensitive) | URL_PROTOCOL | field "x" must use one of [https] |
phone | — | Shorter than 8 or longer than 20 characters after an optional leading +, does not start and end with a digit, or contains anything but digits, whitespace, hyphens, dots and parentheses | PHONE | field "x" must be a valid phone number |
creditcard | — | Fails the Luhn checksum, or contains a non-digit other than space and hyphen | CREDITCARD | field "x" must be a valid card number |
uuid | — | Not canonical 8-4-4-4-12 hex | UUID | field "x" must be a valid UUID |
ulid | — | Not 26 upper-case Crockford base32 characters | ULID | field "x" must be a valid ULID |
cuid | version: v1 | Not c + 24 lower-case alphanumerics | CUID_V1 | field "x" must be a valid cuid v1 |
cuid | version: v2 | Not a lower-case letter + 23–31 lower-case alphanumerics | CUID_V2 | field "x" must be a valid cuid v2 |
cuid | omitted | Matches neither v1 nor v2 | CUID | field "x" must be a valid cuid (v1 or v2) |
nanoid | — | Not 8–64 characters of A–Za–z0–9_- | NANOID | field "x" must be a valid nanoid |
semver | — | Not SemVer 2.0.0 major.minor.patch with optional pre-release and build | SEMVER | field "x" must be a valid semver |
datauri | — | Not a data: URI | DATAURI | field "x" must be a valid data: URI |
uuid accepts any version digit and both hex cases. Pin a specific version by layering a
regex rule on top.
cuid also accepts version: 1 and version: 2 as spellings.
Network
Section titled “Network”| Type | Parameters | Rejects | Code | Default message |
|---|---|---|---|---|
ip | — | Not a parseable IP address | IP | field "x" must be a valid IP address |
ip | version: v4 | Parses but is not IPv4 | IP_V4 | field "x" must be a valid IPv4 address |
ip | version: v6 | Parses but is IPv4 or IPv4-mapped | IP_V6 | field "x" must be a valid IPv6 address |
ipv4 | — | Not a parseable IPv4 address | IPV4 | field "x" must be a valid IPv4 address |
ipv6 | — | Not a parseable IPv6 address, or IPv4-mapped | IPV6 | field "x" must be a valid IPv6 address |
hostname | — | Empty, longer than 253 characters, or not RFC 1123 label shape | HOSTNAME | field "x" must be a valid hostname |
macaddress | — | Not six colon- or hyphen-separated hex octets | MACADDRESS | field "x" must be a valid MAC address |
macaddr | — | Alias of macaddress | MACADDRESS | field "x" must be a valid MAC address |
port | — | Integer outside 1–65535 | PORT | field "x" must be a port number between 1 and 65535 |
ip without a version: accepts both families.
Numeric
Section titled “Numeric”| Type | Parameters | Rejects | Code | Default message |
|---|---|---|---|---|
min | value: | Number below the bound | MIN | field "x" must be ≥ V |
max | value: | Number above the bound | MAX | field "x" must be ≤ V |
minmax | min: | Number below min | MIN | field "x" must be ≥ V |
minmax | max: | Number above max | MAX | field "x" must be ≤ V |
divisibleby | value: | Division leaves a remainder | DIVISIBLEBY | field "x" must be divisible by V |
latitude | — | Outside −90…90 | LATITUDE | field "x" must be a latitude in [-90, 90] |
longitude | — | Outside −180…180 | LONGITUDE | field "x" must be a longitude in [-180, 180] |
Numeric rules accept int, int32, int64, float32, and float64. A numeric string —
"42" sent as JSON text — is not coerced and skips the rule entirely. A divisibleby with a
divisor of 0 is skipped rather than failing.
Membership and substring
Section titled “Membership and substring”| Type | Parameters | Rejects | Code | Default message |
|---|---|---|---|---|
enum | values: [..] | Value not in the list | ENUM | field "x" must be one of [a b] |
in | values: [..] | Value not in the list | IN | field "x" must be one of [a b] |
notin | values: [..] | Value in the list | NOTIN | field "x" must not be one of [a b] |
contains | value: | Substring absent | CONTAINS | field "x" must contain "s" |
notcontains | value: | Substring present | NOTCONTAINS | field "x" must not contain "s" |
startswith | value: | Prefix absent | STARTSWITH | field "x" must start with "s" |
endswith | value: | Suffix absent | ENDSWITH | field "x" must end with "s" |
Membership compares by equality first, then by string rendering — so values: [1, 2] matches
a payload value of "1". values: must be a list; a comma-separated string is not accepted
here.
The four substring rules take their argument from value:. A rule with no argument is
skipped.
| Type | Parameters | Rejects | Code | Default message |
|---|---|---|---|---|
future | — | Instant is not strictly after now | FUTURE | field "x" must be in the future |
past | — | Instant is not strictly before now | PAST | field "x" must be in the past |
Accepted inputs are a native timestamp or a string in RFC 3339 (with or without fractional
seconds), 2006-01-02 15:04:05, or 2006-01-02. Anything else skips the rule. Comparison is
against the server clock at the instant the rule runs, and is strict — an instant equal to now
fails both rules.
mimetype and filesize read the multipart part a client uploaded — its declared content
type and byte length — before the part is written to storage.
| Type | Parameters | Rejects | Code | Default message |
|---|---|---|---|---|
mimetype | types: [..] | Content type matches no entry | MIMETYPE | field "x" file type image/png not allowed |
filesize | min:, unit: | Smaller than the bound | FILESIZE_MIN | field "x" file too small (< N MB) |
filesize | max:, unit: | Larger than the bound | FILESIZE_MAX | field "x" file too large (> N MB) |
types: entries match exactly or with a trailing wildcard: image/* accepts image/png.
unit: is one of B, KB, MB, GB, binary multiples, defaulting to B; an unrecognised
unit is treated as bytes. Both rules skip when the value carries no content type or size —
a plain string reference, for instance.
- name: avatar type: file validations: - type: mimetype types: [image/png, image/jpeg] message: upload a PNG or JPEG - type: filesize max: 2 unit: MBCross-field rules
Section titled “Cross-field rules”A rule that compares two fields belongs at the entity level, in entityvalidations:. Each
entry is a script expression that must return a truthy value.
entities: - name: bookings fields: - name: starts_at type: datetime - name: ends_at type: datetime entityvalidations: - name: window_is_ordered language: expr rule: "row.ends_at > row.starts_at" message: the end of a booking must follow its start code: BOOKING_WINDOW| Key | Meaning | Default |
|---|---|---|
name | Reported as the failing “field” | — |
language | Expression language | expr |
rule | Inline expression body; an empty rule is skipped | — |
message | Overrides the default | entity validation "<name>" failed |
code | Overrides the default | ENTITY_VALIDATE |
Bindings are the same vocabulary access rules use, plus the row under validation:
| Binding | Value |
|---|---|
row | The mutation payload as a map |
<field> | Every payload key, promoted to top level when it does not collide |
user.id, user.roles, user.tenant_id, user.service, user.session_id | Caller identity |
tenant | Tenant key |
entity | Entity name |
action / operation | Create or Update |
user_id, roles | Flat aliases of the nested form |
null, false, 0, "", and the string "false" are falsy; everything else passes. A rule
that throws is reported as a failure with code ENTITY_VALIDATE_ERR and the message
entity validation "<name>" failed to evaluate: <error>.
Ordering, and how many errors you get
Section titled “Ordering, and how many errors you get”Within one field, rules run in declaration order: modifiers first, then validations: top to
bottom. Across fields, the order is unspecified — fields are held in a map and iterated in
whatever order the runtime chooses.
The validator collects every failure it finds, but only the first one reaches the caller: the error value renders as its first element. The structured field, rule, and code are dropped for every failure, including that one — the response carries a message string and nothing else.
What a client sees
Section titled “What a client sees”Field and entity validation failures are typed, so they map to 422 Unprocessable Entity
regardless of how the message is worded. The body is the standard error envelope described in
the HTTP reference.
POST /rest/contactsContent-Type: application/jsonAuthorization: Bearer <token>
{"email": "not-an-address"}{ "error": "hook validate (BeforeValidate): field \"email\" must be a valid email address", "code": "v2.create_failed"}The same failure on PATCH or PUT carries v2.update_failed. The hook <name> (<phase>):
prefix is added by the execution chain and is present on every hook error.
| Outcome | Status | code |
|---|---|---|
| Field or entity validation failed on create | 422 | v2.create_failed |
| Field or entity validation failed on update | 422 | v2.update_failed |
| Unknown validation type in the schema | 422 | v2.create_failed / v2.update_failed |
| Access rule denied the operation | 403 | v2.create_failed / v2.update_failed |
Unknown types fail the write
Section titled “Unknown types fail the write”An unrecognised type: is not ignored. The rule is evaluated, finds no handler, and fails the
mutation:
field "status": unknown validation type "exsts"with code VALIDATION_UNKNOWN. This is a runtime failure, not a load-time one — a schema with
a typo’d rule loads and serves reads normally, and only breaks on the first write that carries
that field. Exercise every write path after editing rules.
Parsed but not enforced
Section titled “Parsed but not enforced”These are accepted by the schema loader and reach the compiled schema, but the shipping service never acts on them. Nothing warns you.
| Declaration | Status |
|---|---|
type: before / type: after | The rule reads its reference instant from a named value parameter that the loader never populates — a value: key lands on the literal-argument slot instead. A declared rule never rejects anything. Express date ordering as an entity validation. |
type: exists with entity: / field: | No handler. Fails every write on that field with VALIDATION_UNKNOWN. Use a reference for referential integrity. |
type: custom | Returns without evaluating. Field-level rules have no script runtime attached; entity validations do. |
type: unique | Shape check only; no database lookup. |
type: writeonly | Lifted to a modifier and then never read. It is not a write-time check, and the read-side hook that strips write-only fields from a result is not registered in data.svc — the value comes back on read. |
when: on a rule | Parsed into the rule’s parameters and never read. There is no conditional validation. |
scope: on unique | Parsed for compound uniqueness and never read. |
region: on phone | Parsed into parameters and never read; the pattern is not locale-aware. |
length / minlength / maxlength on a list field | String-only. A list of any size passes. |
| Numeric rules on a string-typed value | Not coerced. "42" skips min, max, minmax, divisibleby, and port. |
Continue with
Section titled “Continue with”- Entities — the field definitions rules attach to
- Types — what each field type accepts before a rule sees it
- Enums — named value sets, and why they need an explicit rule
- Stateflows — the transition check that runs after validation
- Access rules — the gate that runs before it