checked is a small, standard-library-only package for state objects whose constraints are enforced on every assignment, not just at construction. You declare fields with annotations, attach rules, and from then on the object cannot hold a value that violates them. If you have used Pydantic, you are reading that sentence with a raised eyebrow, because it sounds like Pydantic. The resemblance is less than meets the eye.

Here is an order that is also a small state machine:

1from typing import Annotated
2from checked import Checked
3from checked.constraints import Immutable, Range, OneOf, Transitions, Derived
4
5class Order(Checked):
6 id: Annotated[str, Immutable] = None
7 status: Annotated[str,
8 OneOf("pending", "paid", "shipped", "delivered", "cancelled"),
9 Transitions(
10 pending=["paid", "cancelled"],
11 paid=["shipped", "cancelled"],
12 shipped=["delivered"],
13 delivered=[], # terminal
14 cancelled=[], # terminal
15 )] = "pending"
16 quantity: Annotated[int, Range(1, 99)] = 1
17 is_final: Annotated[bool, Derived(lambda o: o.status in ("delivered", "cancelled"))]

Every line in that class is enforced for the life of the object:

1order = Order(id="A-1001")
2order.status = "paid" # pending -> paid, allowed
3order.status = "delivered" # TransitionError: paid has no transition to delivered
4order.id = "A-1002" # ImmutabilityError: id was set at construction
5order.quantity = 0 # ConstraintError: outside range [1, 99]

None of the rejected writes reach storage; after each one the order is exactly what it was before. That is the core guarantee: if the object exists, every field in it satisfies every constraint, and it stays that way through every mutation.

What this is, and what Pydantic is

Pydantic, like dataclasses and attrs, validates when you build the object. Its job is to take data (a request body, a config file, a row) and turn it into a typed, validated object at the boundary, then serialize it back out. The validation is fundamentally about the shape and type of the data coming in. Pydantic can re-check on assignment if you opt into it, but that is not where its center of gravity sits, and it has no native vocabulary for the thing checked is built around.

checked’s unit of enforcement is the assignment, and its constraints are about how state is allowed to change. The method every constraint implements is validate(field, old_value, new_value, state): it sees the previous value and the whole object, not just the incoming one. That is what lets Transitions express a state machine (legal only if old → new is in the table), Immutable express write-once, Requires express a cross-field prerequisite checked against the current state, and Derived express a computed value that is never stored and recomputes on read. A validator that only inspects the new value cannot say any of those things, because it has no “before” to compare against.

The other half of the distinction is what checked deliberately is not. It does no coercion (the type gate checks, it never casts), no parsing of untrusted input, and no serialization. There is no model_dump_json(), no schema export, no JSON ingestion; as_dict() hands back a shallow snapshot and that is the extent of it. So the split is clean: reach for Pydantic to get data in and validate its shape at the door; reach for checked to keep a long-lived object correct as it mutates. They overlap in syntax far more than in purpose.

The built-in vocabulary is nine constraints (ranges, membership, lengths, a regex, write-once, transitions, cross-field requirements, and derived values), and a constraint is just any object with that validate method, so adding a tenth is a small class with no base to inherit. None of them validate at the boundary, because there isn’t one; the boundary is every =.

pip install checked (or uv add checked) gets you the package. It needs Python 3.12 or newer, pulls in nothing but the standard library, and is MIT licensed.