TypeAdapter for Non-Model Types in Pydantic

Key takeaways:

  • TypeAdapter(list[int]) validates a bare type with no BaseModel wrapper and no envelope key.
  • Build every adapter once at module level; constructing one compiles a validator and is the expensive part.
  • It handles dataclasses, TypedDicts, unions, primitives and any nesting of them, with the usual coercion.
  • validate_json parses and validates in one pass instead of json.loads then walk.
  • Errors are ordinary ValidationErrors, so the loc paths and error types you already handle still work.

This guide sits under Performance Optimization for Models, and it is as much about removing pointless indirection as about speed.

The Problem This Solves

A queue message is a JSON array of integers. A cache entry is a dict[str, float]. A worker hands you a list[Sensor] where Sensor is a dataclass that has existed since long before Pydantic showed up in the project. None of these is an object with named fields, and none of them wants to be a BaseModel.

The usual workaround is to wrap:

class _IdsWrapper(BaseModel):
    items: list[int]

ids = _IdsWrapper(items=payload).items      # a class, an envelope key, and an unwrap

That is three artefacts to express "this should be a list of ints". The wrapper leaks into type hints, the items key has to be remembered at every call site, and if the wrapper ever ends up in a response model the envelope leaks into your API too.

BaseModel wrapper versus TypeAdapterThe upper path wraps a raw JSON list in a BaseModel and has to unwrap an envelope key afterwards. The lower path passes the same payload through a module-level TypeAdapter and gets a validated list directly.raw JSON listwrap in a BaseModelunwrap .items againraw JSON listTypeAdapter(list[int])validated list[int]Build the adapter once at import — never inside the handler.Constructing it compiles the validator; calling it is cheap.

Why It Happens

A BaseModel subclass is two things at once: a schema and a namespace. When you define one, Pydantic's metaclass inspects the annotations, builds a core schema, and compiles it into a Rust validator and a Rust serializer that are then cached on the class.

TypeAdapter separates those two roles. It takes any type expression — list[int], dict[str, float], a dataclass, a TypedDict, an Annotated[...] with constraints, a discriminated union — and performs exactly the same compilation step, but stores the result on an ordinary object instead of on a class. You get the validator without needing a namespace to hang fields on.

This is not an obscure corner of the library. It is the machinery FastAPI itself uses: every query parameter, path parameter and non-model response annotation on your routes is validated through an adapter built from the function signature at import time.

Which points at the one performance rule that matters. Constructing a TypeAdapter runs the schema build and the compile. Calling validate_python runs the compiled validator. The first is expensive and the second is cheap, so an adapter constructed inside a request handler pays the compile cost on every single request — a mistake with the same shape as compiling a regular expression in a loop, and about as costly.

validate_json is a second, separate win. Handing Pydantic the raw bytes lets it parse and validate in a single pass in Rust, rather than having json.loads materialise a full tree of Python objects that the validator then walks and discards. The direction of that difference is reliable; the size depends entirely on your payloads, so measure rather than assume a figure.

The Fix

Build one adapter per type, at module level, and call it wherever the payload arrives.

"""Validate list[int], dicts and dataclasses with TypeAdapter — no BaseModel wrapper."""
from dataclasses import dataclass
from typing import Annotated, TypedDict

from pydantic import Field, TypeAdapter, ValidationError

# Adapters are built ONCE at import time. Building one per call is the whole performance trap.
IntList = TypeAdapter(list[int])
PositiveIntList = TypeAdapter(list[Annotated[int, Field(gt=0)]])
ScoreMap = TypeAdapter(dict[str, float])


@dataclass
class Sensor:
    id: str
    celsius: float
    calibrated: bool = False


SensorAdapter = TypeAdapter(Sensor)
SensorListAdapter = TypeAdapter(list[Sensor])


class RowDict(TypedDict):
    sku: str
    quantity: int


RowAdapter = TypeAdapter(RowDict)


def attempt(adapter: TypeAdapter, value: object) -> dict:
    """Validate and report the real result or the real error, never a summary of one."""
    try:
        result = adapter.validate_python(value)
    except ValidationError as exc:
        return {"ok": False, "errors": exc.errors(include_url=False)}
    return {"ok": True, "value": adapter.dump_python(result, mode="json")}

PositiveIntList is the one to look at twice. Annotated[int, Field(gt=0)] applies a constraint to the element type, which a BaseModel cannot express without inventing a field to hold the list. This is a class of validation that only exists once you can annotate bare types.

Here is what all of it actually does. Real output:

$ GET /validate
200 OK
{
  "list_of_int_from_strings": {
    "ok": true,
    "value": [
      1,
      2,
      3
    ]
  },
  "list_of_int_rejects_text": {
    "ok": false,
    "errors": [
      {
        "type": "int_parsing",
        "loc": [
          1
        ],
        "msg": "Input should be a valid integer, unable to parse string as an integer",
        "input": "two"
      }
    ]
  },
  "positive_int_list_rejects_zero": {
    "ok": false,
    "errors": [
      {
        "type": "greater_than",
        "loc": [
          1
        ],
        "msg": "Input should be greater than 0",
        "input": 0,
        "ctx": {
          "gt": 0
        }
      }
    ]
  },
  "dict_of_float": {
    "ok": true,
    "value": {
      "latency_p99": 12.5,
      "error_rate": 0.0
    }
  },
  "dataclass_from_dict": {
    "ok": true,
    "value": {
      "id": "s-1",
      "celsius": 21.5,
      "calibrated": false
    }
  },
  "dataclass_rejects_bad_field": {
    "ok": false,
    "errors": [
      {
        "type": "float_parsing",
        "loc": [
          "celsius"
        ],
        "msg": "Input should be a valid number, unable to parse string as a number",
        "input": "warm"
      }
    ]
  },
  "list_of_dataclasses": {
    "ok": true,
    "value": [
      {
        "id": "s-1",
        "celsius": 21.5,
        "calibrated": false
      },
      {
        "id": "s-2",
        "celsius": 19.0,
        "calibrated": false
      }
    ]
  },
  "typed_dict": {
    "ok": true,
    "value": {
      "sku": "SKU-1",
      "quantity": 4
    }
  },
  "typed_dict_missing_key": {
    "ok": false,
    "errors": [
      {
        "type": "missing",
        "loc": [
          "quantity"
        ],
        "msg": "Field required",
        "input": {
          "sku": "SKU-1"
        }
      }
    ]
  }
}

Several things in that output are worth naming.

Coercion is identical to a model field. ["1", "2", "3"] became [1, 2, 3], and the dataclass accepted "celsius": "21.5" and produced the float 21.5. Nothing about being outside a BaseModel changes the rules.

Error locations are positional for sequences. The bad element in [1, "two", 3] reported "loc": [1] — the index, not a field name — which maps straight onto the input the caller sent. The element-level constraint behaved identically, flagging index 1 for the zero.

And the plain dataclass behaved like a model. It applied its own default for calibrated, rejected an unparseable celsius, and round-tripped through dump_python(mode="json"). No inheritance change, no decorator, no touching the existing class.

The TypedDict case is the one that most often surprises people: a missing key is a missing error located at the key name, exactly as a required model field would be, even though RowDict is a plain annotation over a dict.

Schemas and raw bytes

An adapter documents itself, which matters when the payload you are describing is not an object:

$ GET /schema
200 OK
{
  "list_of_int": {
    "items": {
      "type": "integer"
    },
    "type": "array"
  },
  "dataclass": {
    "properties": {
      "id": {
        "title": "Id",
        "type": "string"
      },
      "celsius": {
        "title": "Celsius",
        "type": "number"
      },
      "calibrated": {
        "default": false,
        "title": "Calibrated",
        "type": "boolean"
      }
    },
    "required": [
      "id",
      "celsius"
    ],
    "title": "Sensor",
    "type": "object"
  }
}

And parsing bytes straight from a queue or a socket skips the intermediate Python objects entirely:

$ GET /parse-bytes
200 OK
{
  "parsed": [
    {
      "id": "s-9",
      "celsius": 30.25,
      "calibrated": true
    }
  ]
}

That result came from SensorListAdapter.validate_json(b'[{"id":"s-9","celsius":30.25,"calibrated":true}]') — raw bytes to validated dataclasses in one call, with no json.loads in between.

Verification

The regression that costs you real latency is an adapter built in the wrong place, and it is easy to assert against:

def test_adapters_are_module_level():
    """A TypeAdapter constructed per call recompiles the validator every time."""
    source = Path("app").rglob("*.py")
    for path in source:
        for lineno, line in enumerate(path.read_text().splitlines(), 1):
            if "TypeAdapter(" in line and line.startswith((" ", "\t")):
                raise AssertionError(f"{path}:{lineno} builds an adapter inside a function")

Crude, and it catches the thing. If you prefer a runtime check, profile a handler and look for _pydantic_core.SchemaValidator construction in the trace — it should not appear once the process has warmed up.

Then assert the behaviour you actually depend on. Adapters are usually applied at trust boundaries, so the failure case is the interesting one:

def test_queue_payload_rejects_bad_element():
    with pytest.raises(ValidationError) as excinfo:
        IntList.validate_python([1, "two", 3])
    assert excinfo.value.errors()[0]["loc"] == (1,)

Trade-offs and When Not To

Use a BaseModel when the thing genuinely is an object. A request body with named fields belongs in a model — you get the class as a namespace for validators, computed fields, config and methods, and FastAPI gets a named schema in the OpenAPI document. TypeAdapter is for the cases where that namespace would be empty.

Adapters are not free to hold. Each one keeps a compiled validator and serializer alive. A handful per module is nothing; generating them dynamically per tenant or per request in a long-lived process is a slow memory leak. If you must build them dynamically, cache them in a dict keyed by type.

A dataclass is not upgraded by being adapted. TypeAdapter(Sensor) validates data into a Sensor; it does not make Sensor(celsius="warm") raise. Constructing the dataclass directly still bypasses everything, so the adapter has to be on the path the data actually takes.

Errors have no model name to anchor on. A model's ValidationError says which model failed. An adapter's says loc: [1], which is precise about position and silent about what was being parsed. When you surface these to users, add the context yourself — "message 47 in batch 12" is what makes index 1 actionable.

FastAPI request bodies do not need this. Annotating a route parameter as list[int] already causes FastAPI to build an adapter for you. Reach for an explicit TypeAdapter for data that arrives outside the request cycle: queue messages, cached values, JSON columns, third-party webhooks parsed after a signature check, and background jobs.

For the serialization side of the same problem — dump_python versus model_dump_json, and where the time goes — see Pydantic Model Serialization Performance.

FAQ

When should I use TypeAdapter instead of a BaseModel? Whenever the thing you are validating is not naturally an object with named fields — a bare list, a mapping, a dataclass you already own, or a union. Wrapping those in a BaseModel adds an envelope key that then has to be unwrapped everywhere, and buys nothing.

Why must a TypeAdapter be built once and reused? Constructing one compiles a validator and a serializer for the type, which is the expensive part. Building it inside a request handler pays that cost on every request; building it at module level pays it once at import, which is the same thing FastAPI does for your route signatures.

Does TypeAdapter validate dataclasses and TypedDicts? Yes. It handles standard library dataclasses, TypedDicts, NamedTuples, unions, primitives and any nesting of them, applying the same coercion and constraint rules a BaseModel field would get. A real run coerced the string 21.5 into a float on a plain dataclass.

How do I get JSON Schema for a bare type? Call json_schema() on the adapter. It returns the same schema fragment the type would generate as a model field, which is what you need when documenting a payload that is a list or a union rather than an object.

Is validate_json faster than json.loads plus validate_python? It does strictly less work, because Pydantic parses the JSON and validates in one pass in Rust rather than building intermediate Python objects and then walking them. Measure it against your own payloads, but the direction is reliable for large documents.