Replacing backref with back_populates in SQLAlchemy 2.0

Declare the attribute the backref was generating on the other class, with its own Mapped[] annotation, and have both sides name each other with back_populates — the runtime behaviour is identical, and the relationship becomes visible to type checkers, editors and anyone reading the class. This guide belongs to migrating legacy 1.4 code to 2.0 syntax.

Quick Answer

backref creates the other side of a relationship at mapper configuration time, so it exists at runtime and in no source file.

One declaration, or two Left: backref creates Comment.post at mapper configuration time, so the attribute exists at runtime and appears in no source file — a type checker cannot see it, a reader searching for it finds nothing, and its options can only be set through a backref object. Right: back_populates names the other side, which is declared and typed on its own class. backref="post" Comment.post created implicitly not in any source file invisible to type checkers options need backref() back_populates="post" Comment.post declared on Comment typed with Mapped[] visible to editors and readers each side has its own options Both produce the same runtime behaviour; only one of them is visible to the tools you use.

Before — one declaration, two attributes:

from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import DeclarativeBase, relationship


class Base(DeclarativeBase):
    pass


class Post(Base):
    __tablename__ = "posts"
    id = Column(Integer, primary_key=True)
    title = Column(String(200))
    comments = relationship("Comment", backref="post")      # creates Comment.post


class Comment(Base):
    __tablename__ = "comments"
    id = Column(Integer, primary_key=True)
    post_id = Column(ForeignKey("posts.id"))
    # Comment.post exists at runtime. It is declared nowhere.

After — both sides declared and typed:

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


class Base(DeclarativeBase):
    pass


class Post(Base):
    __tablename__ = "posts"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(200))

    comments: Mapped[list["Comment"]] = relationship(
        back_populates="post",
        cascade="all, delete-orphan",
        order_by="Comment.created_at",
    )


class Comment(Base):
    __tablename__ = "comments"

    id: Mapped[int] = mapped_column(primary_key=True)
    post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"))

    post: Mapped["Post"] = relationship(back_populates="comments")

The behaviour is the same: post.comments.append(comment) still sets comment.post in memory, with no query. What changes is that Comment.post is now something an editor can complete, a type checker can verify and a reader can find.

Execution Context & Async Workflow Integration

A relationship() with backref="post" tells SQLAlchemy to construct a second relationship() on the target class when mappers are configured. The two are linked, so they stay synchronised in memory and share one persistence rule — which is exactly what back_populates expresses, declared by hand.

Converting one relationship Four steps. Find the backref and note the attribute name it generated. Declare that attribute explicitly on the other class, with its Mapped annotation and the matching relationship type. Replace backref with back_populates on the original side, naming the new attribute. Move any options the backref object carried — cascade, lazy, order_by, viewonly — onto the side they belong to. find the backref backref="post" the generated attribute name declare it on the other class post: Mapped["Post"] = relationship(...) typed explicitly back_populates on both sides each names the other the pair is one relationship move the options cascade, lazy, order_by Do it one relationship at a time: both styles coexist, so there is no big-bang conversion.

The runtime difference is nil. The differences that matter are all about tooling and legibility.

A generated attribute has no annotation, so a type checker treats comment.post as Any at best and an error at worst. Under SQLAlchemy 2.0's typed declarative style, where the whole point is that Mapped[list["Comment"]] tells mypy what iterating the collection yields, an implicitly generated other side is a hole in that coverage — the reasoning behind typing relationships with Mappedlist.

A generated attribute is invisible to search. A developer looking for where Comment.post comes from finds nothing, because the string "post" appears only as a backref argument on another class.

And its options are awkward to set. Configuring the generated side means replacing the string with a backref() object — backref("post", lazy="joined", innerjoin=True) — which puts both sides' options in one call on one class, in an argument that reads as an afterthought.

Under async there is one more consideration, and it is the practical reason many teams convert: loading strategy. Under AsyncSession, an unplanned lazy load raises rather than querying, so the lazy setting on each side matters. With backref, the generated side's strategy is either the default or buried in a backref() call; with back_populates, it is declared where the attribute is, next to the annotation, and can differ per side — lazy="raise" on a collection that must always be loaded explicitly, and a default on the many-to-one that is cheap to load.

The conversion is incremental. Both styles work in the same model and even in the same class, so there is no coordinated change: convert one relationship, run the tests, commit. That makes it a good first step in a broader 1.x to 2.0 migration, alongside the Column to mapped_column change described in using mapped_column instead of Column.

Resolving Warnings, Errors & Common Mistakes

Exact error or warningRoot CauseProduction Fix
SAWarning: relationship 'Post.comments' will copy column posts.id to column comments.post_id, which conflicts with relationship(s): 'Comment.post'The other side was declared but the two are not linked.back_populates on both sides, naming each other.
ArgumentError: relationship Comment.post refers to attribute Post.comments that is not a relationshipA typo in the back_populates name.Match the attribute name exactly.
ArgumentError: Error creating backref 'post' on relationship 'Post.comments': property of that name existsBoth a backref and an explicit attribute of the same name.Remove the backref; keep the explicit declaration.
AttributeError: 'Comment' object has no attribute 'post'The backref was removed before the explicit side was added.Add the explicit attribute in the same change.
Appending to one side no longer updates the otherThe two sides are separate relationships.back_populates, which is what links them.
MissingGreenlet after the conversion, where there was none beforeThe new declaration's lazy default differs from the backref's.Set lazy explicitly, or add the loader option.
mypy reports the new attribute as untypedThe annotation is missing or uses a string without Mapped.post: Mapped["Post"] = relationship(...).
Which side owns which option Four tiles. cascade belongs on the one-to-many side, because that is where ownership of the children is declared. lazy and order_by belong on whichever side is being loaded, and can differ between the two. viewonly belongs on the side that must not persist. And passive_deletes belongs with the cascade, on the parent. cascade the one-to-many side the parent owns the children lazy, order_by per side they can differ viewonly the read-only side the other side still writes passive_deletes with the cascade on the parent A backref object forced these into one call; two declarations put each where it makes sense.

The first row is the important one, because it is the warning that tells you a conversion is half done. Declaring Comment.post without adding back_populates to Post.comments produces two independent relationships over the same foreign key — the situation described in fixing "relationship will copy column" conflict warnings. They both write comments.post_id, they do not synchronise in memory, and which one wins at flush depends on ordering.

Making mapper configuration part of the test suite catches it immediately, and is worth adding before starting the conversion:

import warnings

import pytest
from sqlalchemy.exc import SAWarning
from sqlalchemy.orm import configure_mappers

import blog.models  # noqa: F401 — importing registers every mapper


def test_mappers_configure_without_warnings():
    with warnings.catch_warnings():
        warnings.simplefilter("error", SAWarning)
        configure_mappers()

The loading-strategy row is the subtler trap. A backref with no options gives the generated side lazy="select". If the explicit declaration sets something else — or if the codebase relied on a backref() call that set lazy="joined" — the query behaviour changes without any test necessarily failing, until an endpoint starts issuing one query per row. Asserting the query count for the paths that use the relationship, with the counter from counting queries per request to catch N+1 regressions, is how that gets caught.

Advanced: Converting backrefs That Carried Options

A bare backref="post" is the easy case. Three variations carry configuration that has to be placed deliberately.

What else changes at the same time Left, legacy: Column for the foreign key, a string class name, backref for the other side, and no annotations, so nothing about the relationship is visible to a type checker. Right, 2.0: mapped_column with a Mapped annotation, both sides declared with back_populates, and the collection typed as Mapped of a list, so an editor knows what iterating it yields. legacy 1.x post_id = Column(ForeignKey(...)) comments = relationship("Comment", backref="post") no types anywhere 2.0 post_id: Mapped[int] = mapped_column(...) comments: Mapped[list["Comment"]] = relationship(back_populates="post") typed on both sides The two changes go together naturally: the annotation is what makes the explicit side worth writing.

A backref() object with its own options. Everything in the call belongs to the generated side, so it moves to the explicit declaration:

# Legacy
class Post(Base):
    comments = relationship(
        "Comment",
        cascade="all, delete-orphan",
        backref=backref("post", lazy="joined", innerjoin=True),
    )
# 2.0 — each side carries its own options.
class Post(Base):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(primary_key=True)
    comments: Mapped[list["Comment"]] = relationship(
        back_populates="post", cascade="all, delete-orphan", passive_deletes=True
    )


class Comment(Base):
    __tablename__ = "comments"
    id: Mapped[int] = mapped_column(primary_key=True)
    post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"))
    post: Mapped["Post"] = relationship(
        back_populates="comments", lazy="joined", innerjoin=True
    )

innerjoin=True is only correct because post_id is NOT NULL — which the annotation Mapped[int] now states, so the two facts sit next to each other rather than in different classes.

A self-referential backref. The generated side needs remote_side to say which end is the parent:

# Legacy
class Category(Base):
    parent_id = Column(ForeignKey("categories.id"))
    children = relationship("Category", backref=backref("parent", remote_side=[id]))
# 2.0
class Category(Base):
    __tablename__ = "categories"
    id: Mapped[int] = mapped_column(primary_key=True)
    parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"))

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

The full treatment of the self-referential shape is in modeling self-referential relationships for trees.

A backref on a secondary relationship. Both sides name the same link table, and both need back_populates:

class Post(Base):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(primary_key=True)
    tags: Mapped[list["Tag"]] = relationship(secondary=post_tags, back_populates="posts")


class Tag(Base):
    __tablename__ = "tags"
    id: Mapped[int] = mapped_column(primary_key=True)
    posts: Mapped[list["Post"]] = relationship(secondary=post_tags, back_populates="tags")

If the link table is also mapped as an association object, one of these needs viewonly=True — the overlap described in using association objects for many-to-many with extra columns.

Finding and Converting Them All

In a large model the work is mechanical, and the only real risk is missing one or converting one incorrectly. Two mechanical aids make it a bounded task.

Three checks per conversion Three checks. Mapper configuration produces no warnings, which catches a pair that is no longer linked. In-memory synchronisation still works: appending to one side sets the other without a query. And the loading behaviour is unchanged, because a backref default and an explicit declaration can easily differ in lazy strategy. configure_mappers() is warning-free an unlinked pair produces the "will copy column" warning both sides still synchronise in memory post.comments.append(c) must set c.post with no query the loading strategy did not change a backref default and your new declaration can differ — assert the query count

Find them. A grep is enough, because backref always appears as a keyword argument:

rg -n 'backref' --include='*.py' | rg -v 'back_populates'

That gives the list, and each hit is one conversion. Working through them in dependency order — leaf models first — avoids touching the same file repeatedly.

Verify each one. Three assertions cover the ways a conversion can go wrong, and they are worth writing once as a helper used by several tests:

import pytest
from sqlalchemy import inspect

from blog.models import Comment, Post


def test_both_sides_are_linked():
    post_side = inspect(Post).relationships["comments"]
    comment_side = inspect(Comment).relationships["post"]
    assert post_side.back_populates == "post"
    assert comment_side.back_populates == "comments"
    # The same persistence rule, seen from both ends.
    assert post_side.mapper.class_ is Comment
    assert comment_side.mapper.class_ is Post


@pytest.mark.asyncio
async def test_in_memory_synchronisation(session):
    post = Post(title="t")
    comment = Comment()
    post.comments.append(comment)
    assert comment.post is post          # no query, no flush

The second test is the one that proves the pair is genuinely linked rather than two coincidental relationships: with back_populates it passes before any flush, and with two independent relationships comment.post is None.

Check the whole registry once. A loop over the mappers finds any relationship that still has no back_populates and no viewonly, which after the conversion should be an empty list:

from sqlalchemy.orm import configure_mappers

from blog.models import Base


def test_every_relationship_is_explicit():
    configure_mappers()
    unlinked = [
        f"{mapper.class_.__name__}.{rel.key}"
        for mapper in Base.registry.mappers
        for rel in mapper.relationships
        if rel.back_populates is None and not rel.viewonly
    ]
    assert not unlinked, f"relationships without back_populates: {unlinked}"

That test is worth keeping permanently. It fails when someone adds a new relationship without linking its other side, which is the same defect the conversion was fixing — and it fails at the moment the code is written rather than when the "will copy column" warning is eventually noticed in a log.

The broader 1.x to 2.0 checklist this fits into — Query to select(), Column to mapped_column, the deprecation warnings that name each remaining call — is in the legacy 1.4 to 2.0 codemod checklist.

Frequently Asked Questions

Is backref deprecated in SQLAlchemy 2.0?

It still works and is not removed, but back_populates is the recommended style: both sides are declared where they can be typed, found by search and configured independently.

Do I have to convert every backref at once?

No. Both styles coexist in the same model and even the same class, so conversions can be made one relationship at a time.

Why do I get a "will copy column" warning after converting?

Because the explicit other side was declared without adding back_populates to the original, so they are two independent relationships over one foreign key. Both sides must name each other.

Where do the backref() options go?

Onto the side they configure. lazy, order_by and innerjoin belong to whichever side is loaded; cascade and passive_deletes belong to the one-to-many side.