Using contains_eager with filtered joins

Use contains_eager() when a query already joins the related table and the collection should be populated from those rows — a join plus selectinload() filters the parents but reloads the whole collection, which looks right and is not. This guide belongs to complex joins and relationship loading strategies.

Quick Answer

Joining and filtering a child table decides which parents come back. It does not decide what their collections contain.

Filtering a join is not loading it Left: the query joins comments and filters them, but the loader option is selectinload, so the collection is loaded by a second query with no filter — the parent rows are filtered while the loaded collection contains everything. Right: contains_eager tells the ORM to populate the collection from the rows the join already returned, so the collection contains exactly the filtered comments. join + selectinload the join filters the parents selectinload reloads the children unfiltered collection and an extra query join + contains_eager one query the collection comes from the join exactly the filtered rows no second query at all Two different jobs: the join decides which parents, contains_eager decides what the collection holds.

Before — the join filters parents, the loader reloads everything:

from sqlalchemy import select
from sqlalchemy.orm import selectinload

from blog.models import Comment, Post

stmt = (
    select(Post)
    .join(Post.comments)
    .where(Comment.approved.is_(True))
    .options(selectinload(Post.comments))      # a second, unfiltered query
    .distinct()
)
# Posts with at least one approved comment — but post.comments contains
# every comment, approved or not.

After — the collection comes from the join:

from sqlalchemy import select
from sqlalchemy.orm import contains_eager

from blog.models import Comment, Post

stmt = (
    select(Post)
    .join(Post.comments.and_(Comment.approved.is_(True)))
    .options(contains_eager(Post.comments))
    .order_by(Post.published_at.desc())
)
# One query. Posts with at least one approved comment, and post.comments
# contains exactly the approved ones.

Putting the filter inside Post.comments.and_(...) keeps it in the join's ON clause, which is what lets isouter=True work later: a filter in WHERE would undo the outer join by discarding the rows where the child columns are NULL.

The collection is now a filtered view of the relationship, which is exactly the point — and the reason it must never be written through.

Execution Context & Async Workflow Integration

contains_eager() does not add anything to the query. It tells the ORM that a join you wrote produces columns belonging to a particular relationship, so the result rows can be used to populate it. That is the whole mechanism, and everything else follows from it.

One query, populated from the join Four steps. The query joins the parent to the child with an explicit ON clause carrying the filter. contains_eager names the relationship the joined columns belong to. The ORM builds parent objects and fills the named collection from the same rows, deduplicating parents by identity. The collection then holds only the joined rows, which is a filtered view rather than the full relationship. join(Post.comments.and_(Comment.approved)) the filter lives in the ON clause one query options(contains_eager(Post.comments)) name the relationship the ORM knows where the columns go parents deduplicated by identity one Post per id collections assembled from rows the collection is a filtered view not every comment The identity map means a Post loaded twice in one session keeps whichever collection was populated first.

Because the collection is built from the joined rows, it contains what the join returned — no more. With a filter in the ON clause, that is the filtered subset. Without contains_eager, those same joined columns would be discarded and the relationship would be loaded separately, unfiltered, which is the bug in the first example.

The three eager-loading options therefore answer different questions. selectinload() and joinedload() load the complete relationship, one with an extra IN query and one with its own outer join — the trade-off in using selectinload vs joinedload for N+1 prevention. contains_eager() adds no query and no join, and gives a partial collection.

Under async this matters more than it might appear, because there is no fallback. A relationship that was not populated raises MissingGreenlet on access rather than lazily loading, so a contains_eager that names the wrong relationship — or a code path that reads a different relationship on the same objects — fails immediately rather than quietly issuing queries.

Parent deduplication is worth understanding. The join returns one row per comment, so a post with five approved comments appears five times; the ORM collapses them by identity into one Post with five comments. That is why .distinct() is unnecessary with contains_eager and actively harmful: SELECT DISTINCT over all the selected columns is expensive and can interfere with ordering. Removing the distinct() that was needed for the join-only version is part of the change.

Two other details commonly come up.

An outer join is needed when parents without a matching child must still appear. join(..., isouter=True) with the filter in the ON clause gives those parents an empty collection — which is only correct because the filter is in ON; in WHERE it would eliminate them.

An aliased join is required when the same relationship is joined twice, or when the query also filters on the same table for a different reason. contains_eager(Post.comments.of_type(alias)) names the alias so the ORM knows which join to read.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
The collection contains rows the filter should have excludedA loader option that reloads the relationship — selectinload or joinedload — alongside the join.contains_eager() instead.
ArgumentError: Can't find property 'comments' on mapper ... in this Querycontains_eager names a relationship the query does not join.Join it, or name the path that is joined.
Parents with no matching child disappearAn inner join, or the filter in WHERE.isouter=True, with the filter inside .and_().
InvalidRequestError about an aliased entity not being presentThe same relationship joined twice without of_type().contains_eager(Post.comments.of_type(alias)).
Rows repeat in the resultReading rows rather than entities, so deduplication never happened.Select the entity, or deduplicate deliberately.
A deleted child reappears, or a delete-orphan deletes too muchThe filtered collection was mutated.Never write through it; use the complete relationship.
MissingGreenlet on a different relationship of the same objectsOnly one relationship was populated.Add loader options for each relationship the code reads.
Three ways to fill a collection Three tiles. selectinload issues one extra query per relationship with an IN list, keeping the row count flat and the collection complete. joinedload adds an outer join and multiplies rows by children, and also returns the complete collection. contains_eager adds no query and no join of its own — it reuses a join you wrote — and returns only the joined rows. selectinload +1 query per relationship complete collection joinedload its own outer join complete collection contains_eager reuses your join filtered collection Only contains_eager gives a partial collection — which is its purpose and its hazard.

The write hazard is the one worth dwelling on, because nothing errors. Consider a post loaded with only its approved comments, where Post.comments has cascade="all, delete-orphan":

post = await session.scalar(
    select(Post)
    .where(Post.id == post_id)
    .join(Post.comments.and_(Comment.approved.is_(True)))
    .options(contains_eager(Post.comments))
)
post.comments.remove(post.comments[0])     # removes one approved comment
await session.commit()

The unapproved comments are not in the collection, and the unit of work compares the collection with what it loaded — which was also only the approved ones — so in this exact case nothing else is deleted. But the moment any code assigns to the collection (post.comments = [...]) or the loaded state is refreshed, the filtered-out rows become orphans from the ORM's point of view. Treating filtered collections as read-only avoids reasoning about which variant is safe.

The clean way to make that explicit is a second, viewonly relationship built for the filtered view:

from sqlalchemy.orm import relationship


class Post(Base):
    __tablename__ = "posts"
    # ...
    comments: Mapped[list["Comment"]] = relationship(
        back_populates="post", cascade="all, delete-orphan"
    )
    approved_comments: Mapped[list["Comment"]] = relationship(
        primaryjoin="and_(Post.id == Comment.post_id, Comment.approved.is_(True))",
        viewonly=True,
        order_by="Comment.created_at",
    )

contains_eager(Post.approved_comments) then populates a relationship SQLAlchemy knows is read-only, so a mutation raises a warning rather than corrupting anything. The overlapping-mappings warning this pair could otherwise produce is covered in fixing "relationship will copy column" conflict warnings.

Advanced: Aliases, Nested Paths and Ordering

Three variations cover the cases a real query runs into.

Two ways to filter a loaded collection Left: an explicit join with contains_eager filters the parents to those having a matching child and loads only matching children in one query. Right: selectinload with and_ on the relationship loads every parent, including those with no matching child, and filters only what the collection contains, in a second query. join + contains_eager parents WITH a match only one query inner join semantics for "show me matching posts" selectinload(rel.and_(...)) every parent, some with empty lists two queries no effect on which parents for "show all posts, approved comments" Choose by whether a parent with no matching child should appear at all.

An aliased join is needed whenever the related table appears more than once, or when the join is also used for filtering elsewhere:

from sqlalchemy import select
from sqlalchemy.orm import aliased, contains_eager

from blog.models import Comment, Post

approved = aliased(Comment)

stmt = (
    select(Post)
    .join(approved, (approved.post_id == Post.id) & approved.approved.is_(True), isouter=True)
    .options(contains_eager(Post.comments.of_type(approved)))
    .order_by(Post.published_at.desc(), approved.created_at)
)

of_type(approved) tells the ORM which join's columns belong to the relationship. Without it, SQLAlchemy cannot connect the aliased columns to Post.comments and raises.

A nested path populates two levels from two joins:

from sqlalchemy import select
from sqlalchemy.orm import contains_eager

from blog.models import Author, Comment, Post

stmt = (
    select(Post)
    .join(Post.comments.and_(Comment.approved.is_(True)))
    .join(Comment.author)
    .options(
        contains_eager(Post.comments).contains_eager(Comment.author)
    )
)

Chaining mirrors the join chain. This is also where row multiplication becomes worth watching: a post with fifty comments returns fifty rows, each carrying the post's columns and the author's, so the wire cost grows with the product of the collection sizes. For two collection levels, selectinload on the outer level and contains_eager on the filtered one is usually the better mix.

Ordering the collection is done in the query, not on the relationship. Because the collection is assembled from the joined rows in the order they arrive, the ORDER BY has to include the child's ordering after the parent's:

    .order_by(Post.published_at.desc(), Comment.created_at)

A relationship-level order_by is ignored by contains_eager, which surprises people who set it once and expect it everywhere.

One more option is worth knowing for the case where the join exists only for filtering and the collection should not be loaded at all: raiseload(). Combining a filtering join with raiseload(Post.comments) says "find the matching posts, and fail loudly if anything touches the collection", which is often exactly the right contract for a list endpoint — see using raiseload to catch unexpected lazy loads.

Choosing Between the Loader Options

With four options available, the decision is easier when framed as two questions.

Three rules for filtered collections Three rules. Never write through a filtered collection: delete-orphan and remove would act on a view that does not contain every row. Use isouter when parents without a match must still appear. And keep the filtered load in a read path, or behind a viewonly relationship built for the purpose, so the write path always uses the complete relationship. never write through it remove() and delete-orphan would treat the filtered-out rows as removed isouter=True when parents must still appear an inner join drops parents with no matching child, silently read paths only, or a viewonly relationship a second relationship declared viewonly documents that it is a view, and SQLAlchemy enforces it

Should a parent with no matching child appear? If yes, the filter belongs in an outer join's ON clause or in a selectinload(...and_(...)). If no, an inner join is the point: it does the filtering.

Should the collection be complete or filtered? Complete means selectinload or joinedload; filtered means contains_eager over a join, and read-only.

The four combinations map cleanly:

from sqlalchemy import select
from sqlalchemy.orm import contains_eager, selectinload

from blog.models import Comment, Post

# 1. Posts that have an approved comment; collections not needed.
matching_posts = (
    select(Post)
    .where(Post.comments.any(Comment.approved.is_(True)))     # EXISTS, no duplication
)

# 2. Posts that have an approved comment, with only those comments loaded.
filtered = (
    select(Post)
    .join(Post.comments.and_(Comment.approved.is_(True)))
    .options(contains_eager(Post.comments))
)

# 3. All posts, with only approved comments loaded.
all_posts_filtered_children = (
    select(Post)
    .options(selectinload(Post.comments.and_(Comment.approved.is_(True))))
)

# 4. Posts that have an approved comment, with every comment loaded.
matching_posts_full_children = (
    select(Post)
    .where(Post.comments.any(Comment.approved.is_(True)))
    .options(selectinload(Post.comments))
)

The first is worth noting as the default: when the collection is not displayed, any() renders an EXISTS and avoids both the duplication and the loading question entirely — the point made in fixing cartesian product warnings in SQLAlchemy joins.

The third is the one people reach for contains_eager to do and do not need to: selectinload accepts and_() on the relationship, filtering the collection without affecting which parents come back, in two clean queries with no row multiplication. For "show every post, with its approved comments", that is simpler and safer than a join.

Which leaves contains_eager for its genuine niche: one query, parents restricted by the join, collections populated from it. That is worth having — a list endpoint that shows matching parents with their matching children in a single round trip — and it is narrower than its prominence in older tutorials suggests. When in doubt, start with any() plus selectinload, measure, and reach for contains_eager when the extra query is the thing that needs removing.

Frequently Asked Questions

What does contains_eager actually do?

It tells the ORM that a join already in the query produces columns for a relationship, so the collection can be populated from those rows. It adds no query and no join of its own.

Why is my collection unfiltered even though the query filters the join?

Because a loader option such as selectinload reloaded the relationship with a second, unfiltered query. Use contains_eager so the collection comes from the join instead.

Can I write to a collection loaded with contains_eager?

No. It is a filtered view of the relationship, so mutations and delete-orphan cascades can act on incomplete state. Keep it in read paths, or declare a viewonly relationship for the view.

When should I use selectinload with and_ instead?

When every parent should appear regardless of whether it has a matching child. selectinload(rel.and_(...)) filters the collection without filtering the parents, in two queries and with no row multiplication.