Collections of Models

Lists, dicts, sets and tuples of models - and what the error path looks like when it is the seventeenth item that is wrong.

Overview

Item by item

Once you can nest one model inside another, the next step is a field that holds many of them. A course has a list of modules. A report has a dictionary of scores. A shape has a pair of coordinates. These are collections of models, and Pydantic validates them item by item.

Each item gets the full treatment. If one module in a list of fifty is wrong, Pydantic tells you which one. The error includes the position of the bad item, so you do not have to hunt through the input to find it. That small detail makes a large difference once the data comes from a user or another system.

This article covers the collection types one at a time. Lists validate each item and put the index in the error path. Dictionaries check both the keys and the values. Sets remove duplicates, which can be a feature or a surprise. Tuples check the length and the type at each position. Along the way you will see what the error paths look like, so you can read them without guessing. It is a short step from nested models, and it covers most of the shapes real payloads take.

The aim is to make a long payload feel as easy to check as a short one. You will not write any loops. You declare the shape, and the library walks it for you, reporting the position of every failure it finds along the way.

Each type behaves a little differently, so the details are worth a look.

A holder annotation shows two things: the shape of the holder and the type of what is in it. Both are enforced.

lessons: List[Lesson]

Given a list, Pydantic walks it and validates each element as a Lesson. A dict turns a Lesson object. An element that is already a Lesson passes through. An element that is neither makes an error. For that element only.

That last part matters more than it sounds. check doesn't abandon the set at the first bad item. It checks all of them and reports all the failures. This For a bulk import is the gap between fixing one row per attempt and seeing the whole problem at once.

Worth knowing

Items are validated one at a time with the inner model's full rules — constraints, validators, defaults and all.
An integer in loc is a sequence index; a string is a field or dict key. ('lessons', 1, 'minutes') is the second lesson's duration.
One bad item does not stop the rest being checked. You get every failure in the collection in a single report.
Set[...] deduplicates. A field can come back with fewer items than were sent, which is usually the intent and occasionally a bug.
Tuple[int, int] is checked by position and length; Tuple[int, ...] means any number of that type.
For a bare JSON array with no enclosing object, use TypeAdapter(List[Model]) rather than inventing a wrapper model with one field.

Collections of Models, and Errors That Know Their Index

Lists, dicts, sets and tuples - what gets checked, what gets converted, and where failures point.

A list of models

Each item is validated separately with the inner model's own rules. Dicts become objects, item by item.

example_01.pyPydantic
Output

The index is in the error path

When one item fails, only that item fails, and the path names its position. The other items still validate.

example_02.pyPydantic
Output

Dicts keyed by something meaningful

Dict[str, Model] validates keys and values independently. The key appears in the error path just as an index would.

example_03.pyPydantic
Output

Sets deduplicate, tuples are positional

A set silently drops repeats, which is usually what you want and occasionally a surprise. A fixed tuple checks length and position.

example_04.pyPydantic
Output

Constraining the collection itself

min_length and max_length apply to the container, so “at least one” is a constraint rather than a validator.

example_05.pyPydantic
Output

A list is not a model - use TypeAdapter

When the payload is a bare array with no object around it, you do not need a wrapper model. TypeAdapter validates the annotation directly.

example_06.pyPydantic
Output

The index is part of the path

An int in loc is a spot. So ("lessons", 1, "minutes") reads as. The lessons field, the item at index 1, its minutes field.

For a payload with a hundred items this is the entire diagnosis. Without it, "one of your lessons has an invalid duration" sends the caller looking through a hundred objects by hand.

Note that the index is the spot in the *input*. This Is what you want. It lets the caller map the error back to the data they sent. If you're surfacing these to a user editing a form with repeated rows, the index is just the row number to highlight.

Dicts, and what the key does

Dict[str, Stats] validates keys against str and values against Stats, on its own.

The key type is a real constraint. A handy one. Dict[int, Stats] will coerce the string keys that JSON forces on you back into ints. This Is a small annoyance handled once instead of at each read site. JSON object keys are always strings. This comes up more often than you'd expect.

In the error path, the key appears where an index would: ("tracks", "maths", "minutes"). Same method, same readability.

A caution about key coercion with untrusted data: Dict[int, X] given {"1": ...} gives you {1: ...}. Two keys that differ only as strings can collide as ints. It's a narrow case. It's the kind of thing worth knowing before it happens.

Sets deduplicate

Set[str] given ["maths", "vectors", "maths"] gives a set of two items. No error, no warning. Deduplication is what a set is for.

That is often the intent. Where it bites is when the copies were real and no one thought about it. A list of tags is fine to deduplicate. A list of readings from a sensor isn't. A Set[float] will silently discard each repeated reading.

Sets are also unordered, so the order you sent isn't the order you get back. model_dump_json has to give an array from a thing with no defined order. If order matters, use a list. And if uniqueness matters too, check it in a checker. This Gives you an error instead of silent removal.

Tuples check length and position

There are two spellings and they mean other things.

Tuple[int, str] is a fixed shape: just two items, the first an int, the second a string. Length is part of the contract. A three-item input fails with too_long.

Tuple[int, ...] is a homogeneous sequence of any length. The same as List[int] except immutable on the way out.

Fixed tuples are good for truly spot-based data. Coords, RGB values, a dim pair. They're a poor choice for anything where the spots have names. point[0] is worse than point.x and no one can tell what config[3] is. If you find yourself writing a comment to explain the spots, you want a model.

Constraining the container

min_length and max_length count items:

lessons: List[str] = Field(min_length=1, max_length=4)

min_length=1 is the handy one. "This must not be empty" is a real rule. Expressing it as a constraint rather than a checker means it appears in the schema as minItems. A made client and the API docs both know about it.

Empty sets are worth a moment's thought often. An empty list is a valid list. Code that assumes at least one element will fail on it. Often with an IndexError far from the model. Deciding by name whether empty is allowed. Writing that choice down, drops a whole group of downstream surprise.

Bare arrays and TypeAdapter

Not each payload has an object around it. An endpoint that returns a JSON array of lessons has no natural wrapper model. The classic workaround is to invent one:

class LessonList(BaseModel):      # a box built only to hold a list
    items: List[Lesson]

TypeAdapter is what that workaround was working around:

lessons = TypeAdapter(List[Lesson])
parsed = lessons.validate_json(payload)

You get the same check, the same error paths. dump_json in the other direction. It also works for anything else you can annotate. Dict[str, float], Optional[int], a bare Lesson, a union.

Build the adapter once and reuse it. Making a TypeAdapter compiles a schema. This Isn't free. Doing it inside a loop is a genuine and easily-missed speed mistake. Module level is the right home.

Performance with large collections

This is the place in Pydantic where check cost turns visible. It's worth being concrete.

checking ten thousand items means ten thousand validations. The work happens in Rust. This Makes it far faster than a Python loop doing the same checks. It isn't free.

The key thing is to let the core do the looping. TypeAdapter(List[Lesson]).validate_python(rows) validates the whole list in one call into the compiled core. Writing [Lesson.model_validate(r) for r in rows] does the same work with ten thousand round trips between Python and Rust. It's measurably slower for no gain.

If you're streaming a very large file, validate in batches rather than building one huge list, for memory reasons rather than check ones.

And apply the rule from the first tier. Validate once, at the boundary. Re-checking a list of models that has already been checked is the most costly no-op on hand.

Choosing the container

List when order matters or copies are real. Which is most of the time.

Set when the values are truly a set and you want deduplication as a feature, not as an accident.

Dict when items are looked up by a key that means a thing. If you find yourself scanning a list to find the item with a matching id on each read, a dict keyed by that id is the shape you wanted.

Tuple for fixed spot-based data, and only when the spots don't deserve names.

Mutable defaults, one more time

The rule from the defaults module has a exact shape here that is worth repeating. Sets are where it bites.

lessons: List[Lesson] = []

This is safe in Pydantic. The default is deep-copied per instance. Two models never share a list. In plain Python the equal would be the classic shared-mutable-default bug. The habit people bring from there's to reach for default_factory reflexively.

That habit isn't wrong, and Field(default_factory=list) is arguably clearer about intent. But it's worth knowing which of the two rules you're following. The reason matters. Use a factory because the default must be *found*, not because a literal would be shared.

Uniqueness without a set

If you want a list. Order preserved, JSON array on the way out — but copies rejected rather than silently dropped, a set is the wrong tool. Deduplication and rejection are other actions.

The check belongs in a checker. This Is the next tier's subject. The shape is worth seeing now:

@field_validator("tags")
@classmethod
def unique(cls, v: List[str]) -> List[str]:
    if len(set(v)) != len(v):
        raise ValueError("tags must be unique")
    return v

The gap from Set[str] is what the caller learns. A set tells them nothing and quietly returns fewer items. The checker tells them their input was wrong. For a form where somebody typed the same tag twice, the second is much more handy.

Nested collections

sets nest as freely as models do, and the error paths keep working:

schedule: Dict[str, List[Lesson]]

A failure inside makes ("schedule", "monday", 2, "minutes"). The key, the index, the field. Four elements. It names just one value in a structure that would else take a while to search by hand.

The hands-on limit is comprehension rather than capability. Dict[str, List[Dict[str, List[int]]]] is valid and no one can read it. Once an annotation needs more than about three levels, extracting a model for the inner shape makes the outer one legible again. And gives the inner shape a name. This Is often the thing that was missing.

Ordering and duplicates in the output

Two small actions that surprise people at output time.

A Set has no order. So model_dump_json makes an array in what order the set iterates. That order is stable within a run but isn't the input order. It isn't sure across runs. If a client diffs your responses, or a test checks JSON strings, a set field will give spurious differences. Use a list and sort it if you need determinism.

A Dict preserves insertion order in modern Python. Dict fields do round-trip in a stable order. That is a language promise rather than a Pydantic one, but it's dependable.

Choosing well, in practice

Most set bugs come from picking the holder for the wrong reason.

Choosing Set because "items should be unique" gives you silent deduplication when what you wanted was an error.

Choosing Tuple because "it shouldn't change" gives you spot-based access when what you wanted was a frozen model.

Choosing List when each read starts with a scan for a matching id means you wanted a Dict and are paying a linear search for it.

The question that resolves all three: what does the *consumer* of this field do with it? Iterate in order, look one up by key, or check membership? Each of those has an obvious holder. The answer is rarely about what the producer finds convenient to send.

What the container says to a reader

A last thought that applies to each set you annotate.

The holder type is docs. List[Lesson] says these are sorted and repeats are real. Set[str] says order is irrelevant and copies aren't a thing. Dict[str, Stats] says you look these up by name. Tuple[int, int] says just two. The spots mean a thing fixed.

Someone reading the model learns all of that without opening a single function. Choosing the holder carelessly. Defaulting to List because it's the one that always works. Throws that away and leaves the reader to infer the rules from the code that consumes it. This Is just the situation models exist to prevent.

Two failure modes to watch for

given non-empty. Code that reads items[0] will raise on a valid empty list. Either constrain the field with min_length=1 so an empty set never reaches you, or handle the empty case by name. Silently given is how a rare payload turns an incident.

given small. A list annotation places no upper bound. A caller can send a million items and your process will try to validate all of them. For any public endpoint, max_length is a cheap denial-of-service guard and a documented limit, and it costs one arg.

Both are the same oversight: a holder's *size* is part of its contract. Leaving it unstated means the contract is what the caller decides.

Where to go after Collections of Models

sets handle many things of the same type. The next module is about the opposite problem. A field that could be one of a few *other* types. How to make that choice clear rather than letting Pydantic guess.

Questions people ask about Collections of Models

How do I validate a list of models? Annotate the field as list[Lesson]. Each item is validated as a Lesson, and an error names the exact index that failed, such as lessons.2.title.

How do I validate a bare JSON array that has no wrapping model? Use TypeAdapter(list[Lesson]) and call validate_python or validate_json on it. You do not need to invent a wrapper class.

Can I require at least one item or cap the length? Yes. Use Field(min_length=1, max_length=50) on the list field. The same keywords work on sets, tuples and dicts.

What happens to duplicates in a set of models? Pydantic builds a set, which needs hashable items. A regular model is not hashable unless it is frozen, so use frozen=True or choose a list and check duplicates yourself.

How does a dict[str, Module] field validate? Both the keys and the values are validated. The key is coerced to str, and each value must be a valid Module.

Is a tuple different from a list? A tuple such as tuple[int, str] checks both length and the type at each position. A tuple[int, ...] accepts any length of ints.

Collections of Models: summary

holders validate their contents item by item. Each failure carries a path that names the exact spot. Index for a sequence, key for a mapping. Nothing is skipped because a thing earlier failed. One report covers the whole set.

Choose the holder for what the consumer does with it, not for what is convenient to build. Constrain the size. An unbounded set is an unstated contract. And when the payload is a bare array with no object around it, use TypeAdapter rather than inventing a wrapper model to hold it.

Check yourself

0 of 4

Answer without scrolling back up.

  1. Item 1 of a 4-item list is invalid. What happens to items 2 and 3?

  2. What does `Set[float]` do with `[1.0, 2.0, 1.0]`?

  3. You need to validate a bare JSON array of models. What is the right tool?

  4. Why is `TypeAdapter(List[X]).validate_python(rows)` better than a list comprehension of `X.model_validate`?

Cheat sheet

Collections of Models

Validate lists, dicts, sets and tuples of models, and get error messages that point to the exact index or key that failed.

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