Modeling self-referential relationships for trees

Give the table a nullable parent_id foreign key to its own primary key, declare parent with remote_side=[id] and children with back_populates="parent" — then load bounded depths with chained selectinload() and unbounded ones with a recursive CTE. This guide belongs to modeling relationships, cascades and association objects.

Quick Answer

A relationship from a table to itself has two ends that look identical to SQLAlchemy. remote_side is what tells it which end is the parent.

remote_side is what names the parent Left: both relationships point at the same table with the same foreign key, and SQLAlchemy cannot tell which end is the parent, so it raises ArgumentError about determining the relationship direction. Right: remote_side equals the id column on the parent relationship, which declares that side the one, making the pair a one-to-many from parent to children. parent = relationship("Category") children = relationship("Category") same table, same foreign key Can't determine relationship direction for self-referential relationship parent: remote_side=[id] children: back_populates="parent" remote_side names the "one" end the pair is one-to-many ordering and cascades work normally remote_side is only needed on the many-to-one side; the collection side infers the rest.

Before — SQLAlchemy cannot tell parent from child:

from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


class Category(Base):
    __tablename__ = "categories"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"))

    parent: Mapped["Category | None"] = relationship()
    children: Mapped[list["Category"]] = relationship()
# sqlalchemy.exc.ArgumentError: Category.parent and back-reference Category.children are
# both of the same direction symbol('ONETOMANY').  Did you mean to set remote_side on the
# many-to-one side ?

After — the parent side declares itself the "one" end:

from sqlalchemy import CheckConstraint, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


class Category(Base):
    __tablename__ = "categories"
    __table_args__ = (
        CheckConstraint("parent_id IS NULL OR parent_id <> id", name="not_own_parent"),
    )

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    position: Mapped[int] = mapped_column(default=0)
    parent_id: Mapped[int | None] = mapped_column(
        ForeignKey("categories.id", ondelete="CASCADE"), index=True
    )

    parent: Mapped["Category | None"] = relationship(
        back_populates="children", remote_side=[id]
    )
    children: Mapped[list["Category"]] = relationship(
        back_populates="parent",
        cascade="all, delete-orphan",
        passive_deletes=True,
        order_by="Category.position",
    )

remote_side=[id] says the id column is on the far side of parent, which makes parent many-to-one and children one-to-many. The index on parent_id matters: every "children of" query filters on it.

Execution Context & Async Workflow Integration

An adjacency list stores one edge per row, which makes writes trivial and reads recursive. That asymmetry is the whole story of working with trees in an ORM.

Depth is where trees get expensive Four steps. Loading a root is one query. Reading its children lazily is one more, and under async it raises instead. Reading each child grandchildren is one query per child, so the count grows with the breadth of every level. A chained selectinload issues one query per level regardless of how many nodes each level holds, and a recursive CTE loads the whole subtree in a single query. load the root SELECT one row 1 query walk children lazily one query per node visited MissingGreenlet under async chained selectinload one query per level fine for known, shallow depth recursive CTE the whole subtree at once Choose by whether the depth is bounded: menus are; comment threads and org charts are not.

Walking a tree by attribute access is one query per node visited. Synchronously that is slow; under AsyncSession it does not work at all, because each step is a lazy load and raises MissingGreenlet. Every traversal therefore has to decide its depth in advance.

For a bounded depth — a navigation menu three levels deep — chained eager loading is the simplest answer, and costs one query per level:

from sqlalchemy import select
from sqlalchemy.orm import selectinload

from shop.models import Category

stmt = (
    select(Category)
    .where(Category.parent_id.is_(None))
    .options(
        selectinload(Category.children).selectinload(Category.children)
    )
    .order_by(Category.position)
)
roots = (await session.scalars(stmt)).all()      # three levels, three queries

The same shape can be declared once on the relationship with lazy="selectin", join_depth=2, which applies to every query that loads a Category — convenient for a model that is always read as a tree, and wasteful for one that usually is not.

For an unbounded depth, no number of chained loaders is correct, and the query belongs in SQL. A recursive CTE walks the whole subtree in one statement:

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

from shop.models import Category


def subtree(root_id: int):
    base = (
        select(Category.id, Category.parent_id, Category.name, literal(0).label("depth"))
        .where(Category.id == root_id)
        .cte("subtree", recursive=True)
    )
    child = aliased(Category)
    return base.union_all(
        select(child.id, child.parent_id, child.name, (base.c.depth + 1))
        .join(base, child.parent_id == base.c.id)
    )

Selecting from that CTE returns every descendant with its depth in one round trip, and the rows can be assembled into a nested structure in Python by parent_id. The mechanics — and how to stop a cycle turning the query into an infinite loop — are covered in implementing recursive CTEs for hierarchical data and detecting cycles in recursive CTEs.

Cascades work the same way they do between two tables, with one extra consequence: ON DELETE CASCADE on a self-referential foreign key deletes the entire subtree, at any depth, in one statement. That is usually what a folder or menu wants, and emphatically not what a comment thread wants.

Resolving Warnings, Errors & Common Mistakes

Exact error or warningRoot CauseProduction Fix
ArgumentError: Category.parent and back-reference Category.children are both of the same direction ONETOMANY. Did you mean to set remote_side on the many-to-one side?No remote_side, so both ends look like collections.remote_side=[id] on the parent relationship.
MissingGreenlet: greenlet_spawn has not been called while walking a treeAttribute-by-attribute traversal is a lazy load per node.Chained selectinload() for bounded depth, a recursive CTE otherwise.
FlushError: Circular dependency detectedTwo rows made each other's parent in one flush.Reject cycles before assigning parent_id.
Recursive CTE never returnsA cycle in the data, so the recursion never terminates.CYCLE detection, or a depth ceiling in the recursive term.
IntegrityError: ... violates check constraint "not_own_parent"A node was assigned itself as parent.Correct behaviour — the constraint caught a bug.
Deleting one node deletes far more than expectedON DELETE CASCADE on a self-referential key removes the whole subtree.Use ondelete="SET NULL" if children should survive.
Children come back in a different order each timeNo order_by on the collection.order_by="Category.position" on the relationship.
Four ways to store a tree Four tiles. An adjacency list, a parent_id column, is trivial to write and needs recursion to read a subtree. A materialised path stores the ancestor chain as text, making subtree reads a prefix match but moves expensive. PostgreSQL ltree is a materialised path with its own index and operators. A closure table stores every ancestor-descendant pair, giving fast reads at the cost of more rows per write. adjacency list (parent_id) trivial writes recursive reads the default materialised path prefix match reads moves rewrite descendants text column ltree (PostgreSQL) indexed path operators extension required good middle ground closure table fastest reads many rows per write deep, read-heavy trees Start with the adjacency list. Add a path or closure table only when a measured read is too slow.

Cycle prevention deserves code rather than a constraint, because a database CHECK can only see one row. A move has to verify that the new parent is not inside the subtree being moved:

from sqlalchemy import select

from shop.models import Category


async def move_category(session, node: Category, new_parent_id: int | None) -> None:
    if new_parent_id is not None:
        if new_parent_id == node.id:
            raise ValueError("a category cannot be its own parent")
        ancestors = await session.execute(ancestor_ids(new_parent_id))   # recursive CTE
        if node.id in {row[0] for row in ancestors}:
            raise ValueError("cannot move a category beneath its own descendant")
    node.parent_id = new_parent_id
    await session.commit()

Without that check the moved subtree becomes a disconnected ring: it no longer appears under any root, every recursive query that starts at a root misses it, and a recursive query that starts inside it never terminates. The rows are all still there, which is what makes the bug so confusing when it is eventually noticed.

Advanced: Bulk Reads, Moves and Deletes

Three operations dominate real tree workloads, and each has a set-based form that avoids walking node by node.

Keeping the tree a tree Three rules. A row must not be its own parent, which a CHECK constraint can enforce outright. A move must not place a node under its own descendant, which needs an ancestor check before the write. And deletes must decide between removing the whole subtree and promoting the children, because doing nothing leaves rows pointing at a parent that no longer exists. a node is not its own parent CHECK (parent_id IS NULL OR parent_id <> id) — one line in the schema a node never moves under its own descendant check the ancestor chain before assigning parent_id, or the subtree leaves the tree deletes: cascade the subtree, or promote the children ON DELETE CASCADE removes everything below; ON DELETE SET NULL makes children new roots

Reading an entire tree for a navigation menu or a category picker is usually better done flat. Select every row once, ordered, and assemble the structure in Python:

from collections import defaultdict

from sqlalchemy import select

from shop.models import Category


async def load_full_tree(session) -> list[dict]:
    rows = (await session.execute(
        select(Category.id, Category.parent_id, Category.name)
        .order_by(Category.parent_id.nulls_first(), Category.position)
    )).all()

    children: dict[int | None, list[dict]] = defaultdict(list)
    for node_id, parent_id, name in rows:
        children[parent_id].append({"id": node_id, "name": name, "children": children[node_id]})
    return children[None]

One query, one pass, and the nested lists are built by reference as the loop goes. For a few thousand nodes this beats any per-level strategy, and it is trivially cacheable.

Moving a subtree in an adjacency list is a single UPDATE of one row: changing a node's parent_id moves everything beneath it, because the descendants' own edges are unchanged. This is the adjacency list's great advantage over materialised paths, where a move rewrites every descendant.

Deleting is where the model choice shows. With ON DELETE CASCADE, deleting a node removes its subtree in one statement. To promote children instead of deleting them, reassign before the delete:

from sqlalchemy import delete, update

from shop.models import Category


async def delete_and_promote(session, node_id: int) -> None:
    node = await session.get(Category, node_id)
    await session.execute(
        update(Category)
        .where(Category.parent_id == node_id)
        .values(parent_id=node.parent_id)          # grandparent, or NULL for a root
    )
    await session.execute(delete(Category).where(Category.id == node_id))
    await session.commit()

Both statements are set-based, so the cost does not depend on how many children there are. The ORM-enabled update() and delete() forms are covered in using ORM-enabled UPDATE and DELETE statements, including the identity-map synchronisation that matters when the session already holds those rows.

If subtree reads dominate and the tree is deep, that is the point to consider a materialised path or PostgreSQL's ltree — a prefix match on an indexed column beats recursion — or a closure table when reads must be fast and writes are rare. Both are optimisations to reach for with a measurement in hand, not defaults.

Ordering, Depth and Presentation

Trees are almost always rendered, and two presentational details cause most of the remaining trouble: sibling order and depth.

Two answers when a branch is deleted Left, ON DELETE CASCADE: deleting a category deletes every category below it, recursively, in a single statement — right for a menu where a removed section takes its items with it. Right, ON DELETE SET NULL: the immediate children lose their parent and become roots, and nothing below them is touched — right when the children are valuable on their own. ondelete="CASCADE" DELETE one row the whole subtree disappears one statement, any depth menus, folders, generated trees ondelete="SET NULL" children become roots grandchildren keep their parents nothing else changes comments, org charts, catalogues Promotion to the deleted node's parent, rather than to a root, needs an explicit UPDATE first.

Sibling order must be explicit. Without order_by on the relationship, children arrive in whatever order the database returns them, which is stable enough in development to look intentional and changes under load, after a vacuum, or when the plan switches to an index scan. An integer position column ordered on the relationship fixes the display order; reordering is then an update of the affected siblings:

from sqlalchemy import update

from shop.models import Category


async def reorder(session, parent_id: int | None, ordered_ids: list[int]) -> None:
    for position, node_id in enumerate(ordered_ids):
        await session.execute(
            update(Category)
            .where(Category.id == node_id, Category.parent_id == parent_id)
            .values(position=position)
        )
    await session.commit()

The parent_id predicate is deliberate: it makes the statement a no-op for an id that is not actually a child of that parent, so a stale client cannot reorder someone else's subtree.

Depth is worth carrying in the query rather than computing in Python. The recursive CTE above already produces it, and it is what lets a template indent without walking parents. It also enables a cheap guard against runaway data: adding WHERE depth < 20 to the recursive term bounds the query even if a cycle slips past the application checks.

For breadcrumbs — the path from a node back to the root — the recursion runs the other way, following parent_id upward:

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

from shop.models import Category


def ancestors(node_id: int):
    base = (
        select(Category.id, Category.parent_id, Category.name, literal(0).label("up"))
        .where(Category.id == node_id)
        .cte("ancestors", recursive=True)
    )
    parent = aliased(Category)
    return base.union_all(
        select(parent.id, parent.parent_id, parent.name, (base.c.up + 1))
        .join(base, base.c.parent_id == parent.id)
    )

Ordering that result by up descending gives root-first breadcrumbs in one query, where attribute access would have cost one query per level and, under async, would not have worked at all.

When a tree is read on every page and changes rarely, denormalising the rendered structure — a JSONB document rebuilt whenever the tree changes — removes the query entirely. Querying and indexing JSONB columns covers storing and invalidating such a document safely.

Frequently Asked Questions

Why do I need remote_side on a self-referential relationship?

Because both ends of the relationship point at the same table, SQLAlchemy cannot infer which side is the "one". remote_side=[id] marks the parent relationship as many-to-one; the children collection then follows.

How do I load a whole tree under async?

Either chain selectinload() once per level when the depth is bounded, or run a recursive CTE and assemble the nested structure in Python. Attribute-by-attribute traversal raises MissingGreenlet.

Does ON DELETE CASCADE work on a self-referential foreign key?

Yes, and it deletes the entire subtree at any depth in one statement. Use ondelete="SET NULL" when children should survive as roots instead.

Should I use a closure table or ltree instead?

Only when measurement says the adjacency list is too slow for your reads. Adjacency lists have the cheapest writes and the simplest moves; paths and closure tables trade write cost for read speed.