Annotated and Custom Types

Naming a constrained type once and reusing it - the habit that stops the same rule being copied into six models.

Overview

The duplication problem

Suppose three models all have a field that holds a positive amount of money. You could repeat the same constraints in each one. That works until the rule changes, and then you have to find and fix every copy. The duplication is the problem, not the rule.

Annotated lets you attach Pydantic rules to a type once and give the result a name. You write the name wherever you need it. The rule lives in one place, the models read better, and the type tells the reader what the field is for.

This article starts with the duplication problem and shows how Annotated solves it. It explains why the result reads better too, since a name like PositiveAmount says more than a bare number. Then it goes further and puts logic into the type, with validators that travel with it. You will see the order in which the pieces run when you stack several of them. The aim is to leave you with a small library of your own types, so the rules of your domain are written down once and used everywhere.

This is the article to read when your models start to feel repetitive. A project with twenty models often has the same five rules written out a hundred times. That is a maintenance problem waiting to happen. One rule changes, three copies get updated, and the fourth is forgotten. A named type fixes that for good. It also gives your team a shared vocabulary, so a field called Slug or Percentage means the same thing everywhere it appears. The article builds up from a bare constraint to a type with its own logic, one small step at a time. By the end you will have a pattern you can copy into any codebase you work on, whatever its size, with very little ceremony.

Every step is small, and each one builds directly on the last.

You write a slug field with a pattern. Then another model needs a slug. The pattern is copied. Then a third. Six months later the rule changes to permit hyphens. Four of the six get updated.

This is the most plain form of drift there's. It doesn't need a clever answer. It needs the rule to exist once.

Worth knowing

Annotated[T, Field(...)] is a type. Bind it to a name and the rule is defined once and reused everywhere.
With Annotated the default sits outside the type — Annotated[int, Field(gt=0)] = 10 — which reads better than tangling both into one Field call.
AfterValidator and BeforeValidator put logic inside the type, so the rule travels with it rather than being a method on one model.
Metadata composes left to right: a before-validator runs, then coercion, then constraints, then an after-validator.
Pydantic ships PositiveInt, NonNegativeInt, StrictInt, AwareDatetime and others — use them rather than rebuilding them.
A named type is also better documentation: slug: Slug says what the field is; a raw regular expression makes the reader work it out.

Annotated and Custom Types: Define the Rule Once

The habit that stops the same constraint being copied into six models.

The same rule, written twice

Two models needing the same constrained field is where duplication starts. It is fine at two and a problem at six.

example_01.pyPydantic
Output

Annotated names the type

Annotated[T, Field(...)] is a real type. Give it a name and the rule exists once.

example_02.pyPydantic
Output

Defaults read better this way

With Annotated the constraint lives with the type and the default sits where defaults normally sit.

example_03.pyPydantic
Output

AfterValidator: logic inside a type

A validator can be part of the type itself, so the rule travels with it instead of being a method on one model.

example_04.pyPydantic
Output

BeforeValidator normalises inside the type

Pair a before-validator with a constraint and the type both cleans and checks — every model that uses it gets both.

example_05.pyPydantic
Output

What ships already

Pydantic includes a set of these. Use them where they fit rather than rebuilding the same thing.

example_06.pyPydantic
Output

Before building your own, check what exists. Pydantic includes a good set:

PositiveInt, NegativeInt, NonNegativeInt, NonPositiveInt, and the Float equivalents.

StrictInt, StrictStr, StrictBool, StrictFloat for per-field strictness.

AwareDatetime, NaiveDatetime, PastDate, FutureDate for time.

Json for a field that holds a JSON string and should be parsed and checked as structured data.

SecretStr and SecretBytes, which are worth knowing: they hide their value in repr and logs. A password or token can't leak into a traceback by accident. The real value comes from .get_secret_value(), which makes each access deliberate and greppable.

Annotated is the mechanism

Annotated[T, ...] is standard typing. It means "this is a T, with extra metadata attached". Static type checkers see straight through to the T while libraries can read the metadata.

Pydantic reads it:

Slug = Annotated[str, Field(pattern=r"^[a-z0-9_]+$")]
Minutes = Annotated[int, Field(gt=0, le=180)]

Slug is now a type. Use it in any model:

class Module(BaseModel):
    slug: Slug
    minutes: Minutes = 10

One meaning, each use site steady, and one place to change it.

It reads better, too

Beyond reuse, there's a readability arg that applies even to a type used once.

slug: str = Field(pattern=r"^[a-z0-9_]+$") tells the reader this is a string and then makes them parse a regular formula to learn anything more. slug: Slug tells them what the field *is*. If they need the details, the meaning is one jump away and has a name attached.

The default placement is better as well. Compare:

minutes: int = Field(default=10, gt=0, le=180)
minutes: Annotated[int, Field(gt=0, le=180)] = 10

Both behave the same way. The second splits the two concerns. What kind of value this is. What it defaults to. And reads as a sentence: a Minutes, defaulting to 10.

Putting logic in the type

Constraints aren't the only thing that can live in an Annotated. checkers can too. This Means a rule needing actual code can still be part of a reusable type rather than a method bolted to one model.

def must_be_known(v: str) -> str:
    if v not in KNOWN_TRACKS:
        raise ValueError("unknown track %r" % v)
    return v

Track = Annotated[str, AfterValidator(must_be_known)]

each model with a Track field now enforces it. Without this, the same @field_validator gets copied into each model. This Is the first problem with extra steps.

BeforeValidator does the same at the other end of the pipeline. This Makes it the tool for scaling:

Slug = Annotated[str, BeforeValidator(to_slug), Field(pattern=r"^[a-z0-9_]+$")]

That type cleans its input and then checks it. Any model using it gets both actions, and neither is written twice.

There's also WrapValidator, which wraps the whole check step and can catch and replace failures. It's the most powerful and least often needed. Reach for it when you want to supply a fallback rather than pass an error.

The order things run in

With a few pieces of metadata, the pipeline is worth knowing:

A BeforeValidator runs first, on the raw input. Then coercion to the base type. Then constraints from Field. Then an AfterValidator, on the final value.

So Annotated[str, BeforeValidator(to_slug), Field(pattern=...)] norms " The Chain RULE " into the_chain_rule and *then* checks it against the pattern. Written the other way round, the pattern would reject the first before anything cleaned it.

Multiple checkers of the same kind run in the order they appear.

Composing types

Named types compose, which is where this starts paying compound interest:

Minutes = Annotated[int, Field(gt=0, le=180)]
Schedule = Dict[str, List[Minutes]]

each int in that nested structure is checked as a Minutes. The constraint applies at each depth, and the annotation stays readable.

This is also how to keep deep annotations comprehensible. Dict[str, List[Annotated[int, Field(gt=0, le=180)]]] is strictly the same thing and no one can read it.

Where to put them

A types.py module beside your models is the usual answer. It turns out to be a truly handy file. Read it and you learn the vocab of the domain. What a slug is, what a duration may be, what an identifier looks like. Without reading a single model.

That is worth more than the deduplication. A named set of domain types is docs that can't go stale. It's the code that runs.

When not to bother

Don't name a type used once with no rule attached. Title = Annotated[str, Field()] is ceremony.

Don't build a custom type where a Literal or Enum is the honest answer. A pattern matching four values is a worse enumeration than an enumeration.

And don't go so far that a reader can't tell what a field really is. slug: Slug is clear. slug: NormalisedConstrainedIdentifier is a name that has stopped helping.

Types that carry documentation

Metadata inside Annotated reaches the schema just as it would from a Field. This Means a named type can carry its own description:

Minutes = Annotated[
    int,
    Field(gt=0, le=180, description="Reading time in minutes."),
]

Now each field of that type is documented the same way, all over, with no repetition. For an API with a dozen models sharing a vocab, that agreement is visible in the made docs and would be hopeless to maintain by hand.

Composing with unions and containers

Named types combine with all else:

Slug = Annotated[str, Field(pattern=r"^[a-z0-9_]+$")]
OptionalSlug = Optional[Slug]
SlugList = Annotated[List[Slug], Field(min_length=1)]

The last one is worth reading twice: the outer Field constrains the *list*. Each item is still checked as a Slug. Constraints at two levels, one line, still legible.

That legibility is the point. The equal written inline is a nested Annotated that no one will want to modify.

Custom types with __get_pydantic_core_schema__

For a type Pydantic knows nothing about. A third-party class, a domain object with its own parsing. There's a protocol to teach it:

class Money:
    @classmethod
    def __get_pydantic_core_schema__(cls, source, handler):
        ...

This is the full escape hatch. It's truly the right answer for a library wrapping its own types.

For use code it's almost never needed. Annotated with a BeforeValidator that makes the object, plus arbitrary_types_allowed if needed, covers nearly each case with a fraction of the machinery. Reach for the protocol when you're writing a library others will use with Pydantic, not when you've one awkward field.

Where a named type goes wrong

Naming the type after the field. ModuleTitle can't be reused by Lesson. This Defeats the purpose. Name it after what it's: Title, Slug, Minutes.

Too many layers. A type built from three other named types is impressive and unreadable. If a reader has to follow three meanings to learn what a field accepts, the abstraction has stopped paying.

**Hiding a Literal.** A custom type checking membership of four strings is worse than the enumeration it's imitating.

Naming a thing with no rule. Name = Annotated[str, Field()] is ceremony with no content.

The file this produces

A types.py in a mature project is one of its most handy docs. Ten or twenty lines defining what a slug is, what a duration may be, what an identifier looks like, what money means here.

Read it and you've the domain's vocab before opening a single model. That is the real return. Not saved keystrokes. A single place where the shape of the domain is stated and can't drift from the code that enforces it.

Annotated and Custom Types: summary

Annotated[T, Field(...)] makes a constraint into a named, reusable type. AfterValidator and BeforeValidator put logic inside that type so rules needing code are reusable too. Metadata runs left to right: before-checkers, coercion, constraints, after-checkers.

Use the types Pydantic already ships. Collect your own in one module. And reach for this the second time you write the same rule, not the sixth.

When to start

The rule of thumb is the second case. The first time you write a constraint, write it inline. The second time you need the same one, name it.

That threshold is low on purpose. The cost of naming a type is one line and the cost of not naming it compounds quietly. Six copies of a rule, four of which are updated when it changes. No one notices the other two until a thing invalid gets through.

Naming early also raises the models at once, before any reuse happens. slug: Slug reads better than a regular formula embedded in a field declaration. The reuse is the payoff. The readability is the down payment.

Mistakes people make with Annotated and Custom Types

Naming the type after the field it first appeared on. ModuleTitle is a type no other model can fairly use. This Drops the only reason to have named it. Title can be used by Lesson, Track and all else.

**Building a type where a Literal is the honest answer.** A constrained string checking membership of four values makes a worse error, a worse schema and worse static checking than the enumeration it's imitating.

Stacking layers until no one can read it. A type composed from three other named types is impressive and opaque. If understanding a field means following three meanings, the abstraction has stopped paying for itself.

**Reaching for __get_pydantic_core_schema__ too early.** The full protocol exists for library authors teaching Pydantic about their own types. use code almost always wants an Annotated with a BeforeValidator instead, at a fraction of the cost.

Putting the metadata in the wrong order. Annotated[str, Field(pattern=p), BeforeValidator(f)] still runs the normaliser first. Before-checkers always precede coercion. But reading it in that order misleads whoever keeps it next. Write the pipeline in the order it runs.

Naming a type with no rule in it. Annotated[str, Field()] is ceremony. If there's nothing to say about the type beyond str, write str.

The return

A named type isn't primarily about saving keystrokes.

It's about there being one place where the shape of a domain idea is stated. About each model that uses it taking from that statement rather than a copy of it.

The reuse blocks drift. The name raises each model it appears in. And the file they live in turns the closest thing a code has to a written description of its own vocab. One that can't go stale. It's the code doing the work.

How this changes a codebase

The visible effect is smaller models. The real effect is that choices stop being scattered.

Before: six models each declare a slug, each with its own copy of a pattern. The meaning of a slug exists only as an emergent property of six places agreeing.

After: one line says what a slug is. Six models refer to it. The meaning exists, in one place, with a name.

That shift matters most at the moments codebases often go wrong. When a rule changes, when somebody new adds a seventh model, when a bug turns out to be one of the six copies having been updated and the others not.

It also changes how the code reads to somebody arriving. Models built from named domain types show the domain. Models built from str and int with regular expressions attached show a output format. The domain has to be inferred.

The method is a single line of typing. The return is a code where the vocab is written down.

Questions people ask about Annotated and Custom Types

What does Annotated do in Pydantic? It attaches extra metadata, such as constraints or validators, to a type without changing the type itself. Pydantic reads that metadata when it builds the schema, while type checkers just see the base type.

Why define a type with Annotated instead of repeating Field(...) on every model? You write the rule once and reuse the name everywhere, so PositiveMinutes means the same thing in ten models. Changing the rule later means editing one line.

In what order do validators inside Annotated run? They run left to right for AfterValidator and in reverse for BeforeValidator, with the type's own validation in the middle. The order in the metadata list is the order you should read it.

Can I put a validator inside an Annotated alias? Yes. AfterValidator, BeforeValidator, PlainValidator and WrapValidator all go inside Annotated[...], and the validator travels with the alias wherever it is used.

When do I need __get_pydantic_core_schema__? Only when Pydantic has no idea how to validate a class you own, such as a third-party object. For most custom rules an Annotated alias with a validator is enough.

Does an Annotated alias show up in the JSON schema? Constraints from Field metadata such as ge or max_length do show up. Custom validator functions do not, because a schema cannot describe arbitrary Python code.

A last practical note

add named types bit by bit rather than in one refactor.

The natural moment is when you next write a rule for the second time. Extract it then, use it in both places. Move on. Repeat that for a few weeks and a types.py builds up on its own, holding just the rules that really repeat. Which is a better pick than any up-front attempt to guess them.

The reverse approach, sitting down to define a full vocab before it's needed, tends to give types nothing uses and abstractions that don't match how the domain turned out.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What is `Annotated[str, Field(pattern=...)]`?

  2. In `Annotated[str, BeforeValidator(f), Field(pattern=p)]`, what runs first?

  3. Why is `Annotated[int, Field(gt=0)] = 10` preferable to `Field(default=10, gt=0)`?

  4. What does `SecretStr` protect against?

Cheat sheet

Annotated and Custom Types

Define a validation rule once with Annotated and reuse it across models instead of copying the same constraint into every field.

PYDANTIC · vizlearn.in/pydantic/annotated_and_custom_types.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.