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.
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.
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 warning | Root Cause | Production 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 relationship | A typo in the back_populates name. | Match the attribute name exactly. |
ArgumentError: Error creating backref 'post' on relationship 'Post.comments': property of that name exists | Both 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 other | The two sides are separate relationships. | back_populates, which is what links them. |
MissingGreenlet after the conversion, where there was none before | The new declaration's lazy default differs from the backref's. | Set lazy explicitly, or add the loader option. |
| mypy reports the new attribute as untyped | The annotation is missing or uses a string without Mapped. | post: Mapped["Post"] = relationship(...). |
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.
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.
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.
Related
- Migrating Legacy 1.4 Code to 2.0 Syntax — The parent guide: the whole migration path.
- Legacy 1.4 to 2.0 codemod checklist — The full list of changes, in order.
- Typing relationships with Mappedlist in SQLAlchemy — The annotations that make the explicit side worth writing.
- Fixing "relationship will copy column" conflict warnings — The warning a half-finished conversion produces.