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(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
from typing import List
from pydantic import BaseModel, TypeAdapter
class Wrapper(BaseModel): # a box built only to hold a list
items: List[int]
print("workaround:", Wrapper(items=["1", "2", "3"]).items)
numbers = TypeAdapter(List[int])
print("direct :", numbers.validate_python(["1", "2", "3"]))
print("from json :", numbers.validate_json("[1, 2, 3]"))
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
from typing import Dict, List, Optional, Union
from pydantic import BaseModel, TypeAdapter
class Lesson(BaseModel):
name: str
minutes: int
cases = [
(TypeAdapter(int), "42"),
(TypeAdapter(Optional[int]), None),
(TypeAdapter(List[str]), ("a", "b")),
(TypeAdapter(Dict[str, float]), {"maths": "4.5"}),
(TypeAdapter(Union[int, str]), "nine"),
(TypeAdapter(List[Lesson]), [{"name": "Direction", "minutes": "4"}]),
]
for adapter, value in cases:
print("%-24r -> %r" % (value, adapter.validate_python(value)))
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
from typing import List
from pydantic import BaseModel, TypeAdapter, ValidationError
class Lesson(BaseModel):
name: str
minutes: int
lessons = TypeAdapter(List[Lesson])
try:
lessons.validate_python([
{"name": "Direction", "minutes": 4},
{"name": "Magnitude", "minutes": "soon"},
{"minutes": 5},
])
except ValidationError as e:
print("problems:", e.error_count())
for err in e.errors():
print(" %-22s %-16s %s" % (".".join(str(p) for p in err["loc"]),
err["type"], err["msg"]))
Output
Serialising and schemas too
It is not only validation. An adapter dumps and generates a schema for the same annotation.
example_04.pyPydantic
import json
from typing import List
from pydantic import BaseModel, TypeAdapter
class Lesson(BaseModel):
name: str
minutes: int
lessons = TypeAdapter(List[Lesson])
data = lessons.validate_python([{"name": "Direction", "minutes": 4}])
print("dump :", lessons.dump_python(data))
print("dump_json :", lessons.dump_json(data).decode())
print()
print("schema :", json.dumps(lessons.json_schema(), indent=1)[:260], "...")
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
import time
from typing import List
from pydantic import TypeAdapter
rows = [[1, 2, 3] for _ in range(300)]
t = time.perf_counter()
for row in rows:
TypeAdapter(List[int]).validate_python(row) # rebuilt every time
inside = time.perf_counter() - t
adapter = TypeAdapter(List[int]) # built once
t = time.perf_counter()
for row in rows:
adapter.validate_python(row)
outside = time.perf_counter() - t
print("rebuilt each time : %.4f s" % inside)
print("built once : %.4f s" % outside)
print("ratio : %.1fx" % (inside / outside))
print()
print("Module level is where an adapter belongs.")
Output
This is the mistake worth naming loudly, because it's invisible and common.
Making a TypeAdaptercompiles 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
import time
from typing import List
from pydantic import BaseModel, TypeAdapter
class Lesson(BaseModel):
name: str
minutes: int
rows = [{"name": "L%d" % i, "minutes": i % 30 + 1} for i in range(2000)]
adapter = TypeAdapter(List[Lesson])
t = time.perf_counter()
one = [Lesson.model_validate(r) for r in rows]
per_item = time.perf_counter() - t
t = time.perf_counter()
many = adapter.validate_python(rows)
whole = time.perf_counter() - t
print("per item : %.4f s" % per_item)
print("whole list: %.4f s" % whole)
print("ratio : %.1fx" % (per_item / whole))
print("same result:", one == many)
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:
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:
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.
Why is `TypeAdapter(List[Lesson])` better than a wrapper model with one list field?
The wrapper exists only to hold the list, forces callers through `.items`, and makes the API document an object where the data is an array.
Where should a TypeAdapter be constructed?
Constructing one compiles a schema. Rebuilding it per call is real repeated work and does not look like a performance bug in review.
Which validates a thousand rows faster?
One call lets the Rust core do the looping; the comprehension crosses between Python and Rust once per item. It is the most effective single-line optimisation here.
When is a model still the better choice than an adapter?
Adapters have no class body and hand back plain objects. Models name domain concepts and give validators, config and methods somewhere to live.
Cheat sheet
TypeAdapter
Validate any type annotation without building a model, using TypeAdapter for standalone checks on lists, dicts and primitives.
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.