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 says only that the entity's property
must be unique — so its message is worth writing: [Unique("That slug is already taken.")]. The sentence is what a
refused write says wherever the duplicate is caught: by the check before the commit, or by the unique index itself — which
is what catches it for a write a workflow or a scheduled job makes.
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 |
[Range(min, max)] | the same inclusive range in one attribute — C#'s spelling of [Min(min), Max(max)] | 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;
}What a person is told when a value breaks one#
A broken bound is refused in a sentence about the FIELD, using its [Label] when it has one and its name in words
otherwise — at the field in the browser, before anything is sent, and in the server's answer if a write gets that far:
| declared | the person reads |
|---|---|
[Max(2100)] int YearFounded; | Year founded must be at most 2100. |
[Label("Vineyard area"), Min(0.5)] decimal Hectares; | Vineyard area must be at least 0.5. |
[MinLength(3)] string Code; | Code must be at least 3 characters long. |
A message you write yourself ([Max(2100, "Give a year before 2101.")]) replaces the sentence. What the row actually held
(Winery.YearFounded held 3000) is kept for the developer — in the server's log and the error's detail — and is not
put in front of the person.
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
[Range(0, int.MaxValue)] int TimesUsed; // a bound may be a numeric constant
[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.
It holds whatever the writer may read. A duplicate is found even when it sits in a row the writer's read rule
hides, or in a column a deny read masks for them — uniqueness is a fact about the stored data, not about one
caller's view of it. The refusal arrives at the write as an ordinary ValidationException that a
catch can handle, and it names the field and nothing else: never the other row, its id or any
of its other values. (A masked column still reads as its default — null, or a value type's zero — in the writer's own queries — see
[[security-entity-security#field-mask-in-queries]].)
⛔ 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.
Taking a [Unique] (or an [Index]) off is an ordinary recompile. The next compile — in place or a new version —
drops the index from the database, so two rows may share the value from then on. Nothing is lost: an index holds no
data, and declaring it again recreates it — once no two rows share the value.
⚠ 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
}An update is judged on the members it changes#
A create is judged on every constraint of the row. An update is judged on the members it assigns: a
[Required], [Min]/[Max]/[Range], [MinLength]/[MaxLength], [Pattern], [Unique] or [Immutable] on a
member the update does not change is not checked again, and its column is not read for it. The stored value was
checked when it was written, and the update leaves it as it was.
entity Listing {
[Required, MaxLength(80)] string Title;
[Min(1)] int Stock;
[MaxLength(400)] string? Note;
}
void Restock(Listing listing, int count) {
listing.Stock = listing.Stock + count; // judged: Stock is assigned
}
void Annotate(Listing listing, string note) {
listing.Note = note; // Title and Stock are not judged: this update leaves them as they are
}So a row whose Stock is 0 — stored before [Min(1)] was declared — can still have its Note changed. Setting
Stock itself to 0 is refused, and so is creating a listing with it.
Two kinds of rule are judged on every write, whatever it assigns, because they read more than one member: an
invariant, and a rule with a when guard.
What keeps stored rows inside a rule you add later is the deploy. A deploy that adds or tightens a [Min],
[Max], [MinLength] or [Pattern] counts the stored rows that break it, and is refused when any do, naming the rule
and the count — so correct those rows in the same deploy, with a migration function. See
[[project-renaming-and-removing#tightening-a-rule]]. A newly [Required] member and a shorter [MaxLength] were
already refused until the migration says what the stored rows become.
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