TypeAdapter

Validation, serialisation and schemas for things that are not models - the piece most people meet late and wish they had met early.

Overview

The thing people work around

Pydantic's validation works on models, but not all your data comes wrapped in one. Sometimes you have a plain list of integers. Sometimes a dictionary of names to scores. Sometimes a union of types. You still want the same checking and the same errors.

People often work around this. They build a throwaway model with one field, just to borrow validation. That is clumsy, and it is unnecessary. TypeAdapter takes any type and gives it the same validation and serialisation surface that a model has.

This article starts with the workaround people reach for and why it is a smell. It shows that a TypeAdapter offers the same methods as a model, and lists what you can pass it, from simple types to complex ones. It makes the point that you should build the adapter once and reuse it, because construction has a cost. You will see how to validate a whole collection in a single call, and how to get a schema from an adapter. It is the tool for data that does not need a class of its own.

Many projects reach this point sooner or later, usually with a list of records that has no model of its own. Maybe it is a config value, a webhook body or a column of data you want to check. The adapter handles all of these without any extra class. Because it follows the same rules as a model, you do not need to learn a second set of behaviours. The errors look the same. The modes work the same. Even the schema output matches. This article introduces the adapter through the workaround it replaces, then shows what it can do and how to use it without paying for it more than once. That last point matters in a busy service, because building an adapter on every call is a quiet and avoidable cost in code that runs often.

A model validates a model. But plenty of data isn't shaped like one.

An endpoint returns a bare JSON array. A config value is a Dict[str, float]. A function takes a List[UUID] and you'd like to check it. A queue message is a single int.

The common workaround is a wrapper model:

class LessonList(BaseModel):
    items: List[Lesson]

A box built only so that a thing can be inside it. Each caller has to reach through .items to get at the actual data.

TypeAdapter is what that was working around:

lessons = TypeAdapter(List[Lesson])
lessons.validate_python(rows)

Worth knowing

TypeAdapter(T) applies the whole machinery to any annotation — no model needed.
It has the same surface as a model: validate_python, validate_json, dump_python, dump_json and json_schema.
Errors are identical in shape to a model's, so one error handler covers both.
Build the adapter once, at module level. Constructing one compiles a schema, and doing it per call is a genuine cost.
Validating a whole list in one call lets the Rust core do the looping, which beats a comprehension calling model_validate per item.
It is the right tool for a bare JSON array, a config dict, a function argument, or anything else without a natural model around it.

TypeAdapter: Validation Without a Model

The same machinery applied to any annotation - and the fastest way to validate a large set.

The wrapper model you do not need

A bare list has no object around it. The usual workaround is a one-field model; TypeAdapter is what that was working around.

example_01.pyPydantic
Output

It works on anything you can annotate

Scalars, containers, unions, models, and combinations of them all go through the same machinery.

example_02.pyPydantic
Output

The same errors you already know

Paths, types and messages are identical to a model's, so one error handler covers both.

example_03.pyPydantic
Output

Serialising and schemas too

It is not only validation. An adapter dumps and generates a schema for the same annotation.

example_04.pyPydantic
Output

Build it once

Constructing an adapter compiles a schema. Doing that inside a loop is a real and easily missed cost.

example_05.pyPydantic
Output

This is the mistake worth naming loudly, because it's invisible and common.

Making a TypeAdapter compiles a schema. That is real work. The same work that happens once when a model class is defined.

for row in rows:
    TypeAdapter(List[int]).validate_python(row)     # recompiles every iteration

The adapter belongs at module level, built once and reused:

NUMBERS = TypeAdapter(List[int])

for row in rows:
    NUMBERS.validate_python(row)

The gap is large and it doesn't look like a speed bug in review. This Is just why it lives.

Validating a whole list in one call

Letting the core do the looping beats a comprehension that crosses into Python for every item.

example_06.pyPydantic
Output

The same surface as a model

An adapter has the methods you already know, applied to the annotation instead of to a class:

validate_python and validate_json for input. dump_python and dump_json for output. json_schema for the schema.

So all from the previous modules applies. Coercion works the same way. Constraints inside Annotated are honoured. Errors have the same loc, type, msg and input. The error handler you already wrote covers adapters without change.

That last point is worth emphasising: this isn't a parallel API with its own rules. It's the same machinery, handled differently.

What you can pass it

Anything you can write as an annotation.

Scalars: TypeAdapter(int). holders: List, Dict, Set, Tuple. Optionals and unions, including discriminated ones. Models. Constrained types from Annotated. Any nesting of those.

TypeAdapter(Annotated[int, Field(gt=0)]) validates a bare plus int. This Is at times just what a function arg needs.

Validating a collection in one call

The second speed point is the more valuable one, and it applies to models too.

Two ways to validate a thousand rows:

[Lesson.model_validate(r) for r in rows]              # per item
TypeAdapter(List[Lesson]).validate_python(rows)       # whole list

Both give the same result. The first crosses from Python into Rust and back a thousand times. The second makes one call. The loop happens inside the compiled core.

For bulk work — importing a file, handling a batch, checking a large response — that is the single most good tuning on hand in this library, and it's one line.

This is also the answer to the concern raised back in the first tier about check cost on large sets. The cost is real. The way to reduce it's to let the core do the looping rather than to skip checking.

Where it earns its place

Bare arrays. An endpoint that accepts or returns a JSON list.

config. A Dict[str, str] from setting or a file, checked without inventing a settings model for three values.

Function args. Checking an arg at a public function's edge without wrapping it.

Bulk check. The speed case above.

Dynamic types. Because it takes a type at runtime, you can build one from a type found at runtime. handy in plain code and in libraries.

Schemas without models

json_schema() makes a schema doc for the annotation. This Is how a framework docs an endpoint whose body is a bare array.

That is the piece that makes the wrapper model truly not needed. before you needed the model to get a schema. Now the annotation is enough. Your API docs shows an array as an array rather than as an object with an items field no one sends.

When a model is still better

TypeAdapter isn't a swap for models. Reaching for it all over would be a mistake.

A model gives you a name. Lesson means a thing. Dict[str, Any] doesn't. Named types are how a code stays comprehensible.

A model gives you a place for checkers, config and methods. An adapter has no class body.

A model gives you field access. lesson.minutes beats data["minutes"]. Your editor can help with the first.

So: models for the domain concepts, adapters for the shapes around them. A List[Lesson] is an adapter wrapping a model. That is the usual setup. The model names the thing, the adapter handles the set.

A small caution

An adapter validates and hands back plain Python objects. TypeAdapter(Dict[str, float]) gives you a dict, not a thing with promises attached.

Nothing stops later code putting a string in that dict. check happened at a moment. It isn't a permanent property of the object. This Is the same as all over else in Pydantic and worth remembering when the checked thing is a mutable builtin rather than a model with frozen=True on hand.

Adapters and constrained types

Because an adapter takes any annotation, it takes constrained ones:

Port = Annotated[int, Field(ge=1, le=65535)]
PORTS = TypeAdapter(List[Port])

PORTS.validate_python(["80", "443"])     # [80, 443]
PORTS.validate_python([0])               # ValidationError

That is a compact way to validate a list of values against a domain rule with no model anywhere. config lists, command-line args and query params are all natural fits.

Validating function arguments

An adapter at a public function's edge gives you the same promises a model would, without wrapping the args in an object:

IDS = TypeAdapter(List[UUID])

def fetch(ids):
    ids = IDS.validate_python(ids)
    ...

Pydantic also ships @validate_call. This Reads a function's own annotations and validates args on its own. That is often the nicer spelling when a whole function should be checked. An adapter is better when only one arg needs it, or when the check is gated.

The schema for a bare shape

json_schema() is what makes the wrapper model truly not needed rather than merely inconvenient.

Before adapters, an endpoint accepting a bare JSON array needed a model to give a schema. The resulting docs given an object with an items field that no client ever sent. With an adapter the schema shows an array. An array is what it's.

FastAPI uses this inside. This Is why annotating a request body as List[Item] makes correct docs with no wrapper in sight.

Adapters are cheap to hold, not to build

The gap that matters for speed: an adapter is costly to *make* and cheap to *use*.

Making compiles a schema. Using it runs compiled code. So the pattern is always the same. Build at module level, use anywhere:

LESSONS = TypeAdapter(List[Lesson])

Uppercase by rule, because it's a module-level constant. Putting it at the top makes it obvious it's built once.

If a type is only known at runtime, cache the adapters in a dict keyed by type rather than rebuilding. functools.lru_cache on a small factory function is the usual shape.

A short list of uses

A bare JSON array in or out. A config dict. A function arg at a public edge. Bulk check of a large set in one call. A shape whose type is found at runtime. A schema for a thing that isn't a model.

Each of those has a wrapper-model workaround, and each is cleaner without one.

TypeAdapter: summary

TypeAdapter applies the whole machinery. Check, coercion, constraints, output, schemas, errors — to any annotation, with the same action and the same error shapes as a model.

Build it once at module level. Prefer one call over a Python loop for sets. Use models where a thing deserves a name and a class body. Use adapters for the shapes around them.

It's the piece most people find late. The one that drops the most awkward code when they do.

Why it is worth learning early

Most people discover TypeAdapter after writing a few wrapper models. Then go back and delete them.

The habit it replaces is small but pervasive. Reaching for a class because check seemed to need one. Once you know an annotation is enough, a whole group of awkward code stops being written. The box around the list, the .items each caller has to unwrap, the schema that shows an object where the data is an array.

Models for things that deserve names. Adapters for the shapes around them. That division is most of the judgement this module is trying to pass on.

Mistakes people make with TypeAdapter

Making inside a loop. The single most costly mistake here. The one that looks fine in review. Building an adapter compiles a schema. Doing it per loop repeats that work each time. Module level, uppercase, once.

Looping in Python over a set. [Model.model_validate(r) for r in rows] crosses between Python and Rust once per row. TypeAdapter(List[Model]).validate_python(rows) makes one call and loops inside the compiled core. For bulk work it's the highest-value one-line change on hand.

Using an adapter where a model belongs. A Dict[str, Any] passed around a code is a shape with no name, no methods, no field access and no place to put a rule. If the thing is a domain concept, it deserves a class.

given check persists. An adapter hands back plain Python objects. A checked Dict[str, float] is an plain dict. Nothing stops later code putting a string in it. check happened at a moment. It isn't a property the object carries later.

Losing it can serialise and make schemas. People discover validate_python and stop. dump_json and json_schema are the other half. The schema in given is what makes the wrapper model truly not needed rather than merely inconvenient.

Rebuilding adapters for runtime types without caching. When the type is found, cache the adapters in a dict keyed by type, or wrap a small factory in functools.lru_cache.

TypeAdapter: the short version

Any annotation, the whole machinery, no class needed.

Build it once at module level. Build compiles a schema. Validate sets in one call rather than looping in Python. And use it wherever the data has no natural model around it. Which is more often than the wrapper-model habit suggests.

A note on naming and discovery

One reason TypeAdapter is found late is that it doesn't look like the thing people are searching for. Someone with a bare JSON array searches for how to validate a list, finds examples using models. Builds the wrapper. Nothing in that path mentions adapters.

So it's worth stating the shape plainly: if you can write it as a type annotation, you can validate it, serialise it. Make a schema for it, without a class.

That covers a great deal. A list of models. A dict of settings. A single constrained int. An optional union. Anything nested from those.

The corollary is equally handy: if you're writing a class purely so that a thing can be checked, stop and check whether an annotation would do. The wrapper model is a real and widespread pattern. Almost each instance of it predates its author discovering this.

Where a class still earns its place is where a class was always the right answer. A domain concept that deserves a name, one spot to hang methods, field access instead of subscripting, a place for checkers and config. Those are properties of a model, not of check. Adapters were never competing for them.

Questions people ask about TypeAdapter

What is TypeAdapter for? Validating and serialising any type without defining a model, such as list[int] or dict[str, Course].

How do I use one? Build it once with ta = TypeAdapter(list[int]), then call ta.validate_python(data), ta.validate_json(text) or ta.dump_python(value).

Why create it at module level? Constructing a TypeAdapter builds a core schema, which is slow to repeat. Build it once and reuse it.

Can it generate a JSON schema? Yes. ta.json_schema() returns the schema for the type, even if it is a bare union or list.

Does it work with dataclasses and TypedDict? Yes. It accepts dataclasses, TypedDict, NamedTuple and unions, which a plain model cannot wrap.

When is a model still better? When you want named fields, methods, config and a class to import. TypeAdapter is for shapes that are not worth a class.

Where it sits in the library

It's easy to read this module as being about an tuning or a convenience. It's really about a boundary in how Pydantic is organised.

BaseModel is a way of *declaring* a shape and getting action attached to it. TypeAdapter is a way of *using* the same machinery against a shape declared some other way.

all the library does — coercion rules, constraints, error paths, JSON parsing, output, schema making — lives underneath both. A model isn't a more capable checker. It's a class with that checker bound to it, plus a namespace for methods and config.

Seeing it that way makes the choice obvious rather than a matter of taste. Ask whether you need the class. If the answer is yes. Because the thing has a name, needs methods, wants field access. Write a model. If the answer is no, an annotation and an adapter give you same check with nothing extra to maintain.

Check yourself

0 of 4

Answer without scrolling back up.

  1. Why is `TypeAdapter(List[Lesson])` better than a wrapper model with one list field?

  2. Where should a TypeAdapter be constructed?

  3. Which validates a thousand rows faster?

  4. When is a model still the better choice than an adapter?

Cheat sheet

TypeAdapter

Validate any type annotation without building a model, using TypeAdapter for standalone checks on lists, dicts and primitives.

PYDANTIC · vizlearn.in/pydantic/type_adapter.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.