Summary#
Constraints are per-member rules enforced when the row is written. They are not form validation you can forget to call: a row that breaks one cannot reach the database, whatever code path tried to write it — a function, an import, a test, an API call.
Signature#
[Unique(<Member>, <Member>)] // …or over a COMBINATION, declared on the entity
entity <Name> {
[Required] <Type> <Member>; // must be present
[Unique] string <Member>; // no two rows may share a value
[MaxLength(<n>)] string <Member>; // bounded text
[Min(<n>), Max(<n>)] int <Member>; // an inclusive numeric range
[Pattern("<regex>")] string <Member>; // must match
}Every constraint takes an optional message as its last argument — what the user is told when the rule refuses them:
[Pattern("<regex>", "<message>")] string <Member>;
[MaxLength(<n>, "<message>")] string <Member>;
[Required("<message>")] <Type> <Member>;
[Unique("<message>")] string <Member>; // shown when the value collides with another row…including the entity-level composite form, where the message goes last, after the members:
[Unique(<Member>, <Member>, "<message>")]
entity <Name> { … }[Unique] is the one whose default wording helps least — a collision otherwise surfaces as the raw index name — so
its message is worth writing: [Unique("That slug is already taken.")].
Description#
Which constraint attributes are there?#
| Attribute | Rule | On a null value |
|---|---|---|
[Required] | the member must be present when the row is written | this is the one that rejects null |
[Unique] | no two rows may hold the same value | passes — nulls do not collide |
[MaxLength(n)] · [MinLength(n)] | text is at most / at least n characters | passes |
[Min(n)] · [Max(n)] | an inclusive numeric range | passes |
[Pattern("…")] | text matches the regular expression | passes |
[Immutable] | may be set on create, but never updated | passes (it governs updates, not presence) |
[Precision(p, s)] | a decimal stored with p total digits and s after the point | passes |
[MaxBytes(n)] | the value's stored size is at most n bytes | passes |
Read that last column carefully: every constraint except [Required] lets null through. That is the correct
behaviour — "if there is a value, it must look like this" is a different rule from "there must be a value" — but it
surprises people. If a code must be present and well-formed, say both:
entity Product {
[Required, Pattern("^[A-Z]{3}$")] string Code; // must exist, and must be three capitals
[Pattern("^[A-Z]{3}$")] string AltCode; // may be absent; if present, must match
}Give a [Pattern] a message. Every other constraint refuses in words a person can act on — "Email is required",
"Name is too long". A pattern refuses with the regular expression, which explains nothing to the person reading it:
entity Product {
[Required, Pattern("^[A-Z]{3}$", "must be three capital letters")] string Code;
}Can I stack several on one member?#
Attributes stack, in one bracket or several — whichever reads better:
entity Coupon {
[Required, Unique, MaxLength(20)] string Code; // present, one of a kind, bounded
[Min(1), Max(100)] int PercentOff; // inclusive: 1 and 100 both pass
[MaxLength(500)] string Notes; // optional, but bounded when given
}[Required] on a reference forbids an orphan#
[Required] works on a reference too, and it is how you say "this child cannot exist without
its parent":
entity Order {
[Required] string Code;
}
entity LineItem {
[Required] Order Order; // a line with no order is a violation, not a stray row
decimal Amount;
}[Unique] is enforced by the database#
[Unique] is a real unique index, not a check-then-insert in application code. That distinction matters under
concurrency: two requests racing to claim the same coupon code cannot both win, because the second one is rejected by
the database rather than by a check that already passed.
⛔ AND A SWAP BETWEEN TWO EXISTING ROWS IS REFUSED — the whole commit rolls back, silently. This is the one interaction an ordered list actually performs, and it is the opposite of what most people assume, so it is worth stating plainly:
[Unique] int Position;
// …then, in an action:
int mine = job.Position;
job.Position = above.Position; // ← both rows now hold the same value for an instant
above.Position = mine;
UnitOfWork.Commit(); // ← the index refuses; NOTHING is writtenA unique INDEX is checked per statement, not at commit, so the intermediate state where two rows share a value is
rejected even though the state you were committing is legal. Measured: the two rows come back unchanged, and
before 2026-08-29 the refusal reached nobody — no error, no banner, no fault in the test. It now surfaces in a
failing test as action 'MoveUp' failed: Constraint violation: 'Job.Position' must be unique, but the write still
does not land.
So for a position column, do one of these:
leave it un-[Unique] | the ordinary answer. A rank has no meaning in a gap or a repeat beyond "which comes first", and nothing else depends on it being distinct. |
| write the midpoint instead of swapping | job.Position = (above.Position + below.Position) / 2m over a decimal — one row changes, so no two rows ever collide. This also stops a move rewriting the whole tail. |
⚠ [Unique] is not deferrable today, and that is why. Foreign keys are (they are emitted as deferrable
constraints); [Unique] is emitted as a CREATE UNIQUE INDEX, and Postgres cannot defer an index. Making it
deferrable would make the swap above work as written — it is a real option and it is not built.
Over a COMBINATION of members, write it on the entity — [Unique(A, B)] above the declaration, naming two or
more members. It is the same real unique index, over the pair — and it takes the same optional message, written
last: [Unique(A, B, "…")]. That is one form, not two, and it is the one to reach for. Without the message the
person is shown the index's own words; with it, they are told what they did:
entity Channel {
[Required, MaxLength(60)] string Name;
}
[Unique(Channel, Person, "They are already in this channel.")]
entity Membership {
[Required] Channel Channel;
[Required] User Person;
}
[Principal] entity User {
[Required, MaxLength(200)] string Email;
}This is the constraint a join table wants, and reaching for a Membership.Any(…) guard instead is the mistake the
paragraph above describes: two requests can both read "not a member" before either writes, and only the index is
enforced where that race is. Keep the guard as well if you want a quiet no-op on a double-click — but it is the
convenience, not the rule.
Which field does the violation land on? For a composite one, a field whose name is the members joined with
", ", in declaration order — Channel, Person for the Membership above. Not either member on its own: the rule
is about the combination, so what is refused is the combination, named as one thing. That is what a test aims at, and
it is the one string you need:
Assert.Violation("Channel, Person"); // the pair collided
Assert.Violation("Channel, Person", "already in this channel"); // …and this is what it saysA single-member [Unique] is the ordinary case — the field is just the member. Either way the violation is raised
by the server, at the save, because "is this taken?" is a question about other rows; see
[[testing-ui#composite-unique]] for when a test may assert it.
⚠ A unique constraint is over a table, and one table holds a whole inheritance hierarchy — so one declared on a base spans every type derived from it. That is usually what you want, and the compiler says so either way (it warns, naming every type covered).
Write-once, decimal precision, and size bounds#
Four constraints shape a value beyond "is it valid":
[Immutable]is a rule about updates, not presence: the member may be set when the row is created and then never changed. It is how you say "an order's placed-date is written once" — an attempt to update it is refused, from any code path. Pair it with[Required]when the value must also be present from the start.[MinLength]is the floor to[MaxLength]'s ceiling:[MinLength(n)]requires text of at leastncharacters.[Precision]pins how adecimalis stored:[Precision(p, s)]givesptotal significant digits,sof them after the decimal point.[Precision(10, 2)]is the shape of money — up to eight digits before the point, two after.[MaxBytes]bounds the stored size of a value in bytes:[MaxBytes(n)]caps a large field — aJsondocument or rich text — where the limit you care about is storage, not character count.
entity Invoice {
[Required, Immutable] DateTime IssuedAt; // set once, at creation; never edited afterwards
[Precision(10, 2)] decimal Amount; // money: 8 digits before the point, 2 after
[MinLength(3), MaxLength(20)] string Number; // a bounded reference code
[MaxBytes(1048576)] Json Payload; // at most 1 MB of stored JSON
}The rule spans several members — what then?#
A constraint speaks about one member. When the rule spans several — "shelf days must be under 30, but only for perishable items" — you want an invariant.
See also#
- invariant — rules that span several members of the row
- entity members — the members these constrain
- relations —
[Required]on a reference