Обратные связи: back_populates
Связь, читаемая в обе стороны: task.user знает автора, user.tasks — задачи, а back_populates следит, чтобы концы не разошлись.
Редакция Питоники
Связь из прошлого урока читается только вниз: у Анны есть tasks, а у её задачи — только user_id, число в колонке. Спросить «кто автор?» объект задачи не может — приходится идти вторым запросом и подставлять id руками. В настоящем коде связь нужна в обе стороны: у карточки задачи показывают исполнителя, у профиля автора — его задачи. Сегодня достраиваем обратную сторону и знакомимся с параметром, который не даст двум концам связи разойтись.
Механика простая. У Task появляется свой relationship — ссылка на автора: task.user. А чтобы SQLAlchemy знал, что user.tasks и task.user описывают одну и ту же связь, оба объявления снабжаются зеркальным параметром back_populates. С этого момента два атрибута живут одной жизнью: меняешь один — второй обновляется сам, без перечитывания базы.
Двусторонняя связь: оба конца
Соберём полную модель и назначим задачу автору через ссылку task.user. Ни одного id руками — и посмотрим, что произойдёт с коллекцией у Анны до и после сохранения.
from sqlalchemy import create_engine, ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task", back_populates="user", order_by="Task.id")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
user = relationship("User", back_populates="tasks")
def __repr__(self):
return f"Task({self.title!r})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
boris = User(name="Борис")
t = Task(title="отчёт")
s.add_all([anna, boris, t])
s.commit()
t.user = anna # назначили автора одной строкой
print("в памяти:", [x.title for x in anna.tasks])
s.commit()
print("после commit:", [x.title for x in anna.tasks])
print("user_id:", t.user_id)
в памяти: ['отчёт'] после commit: ['отчёт'] user_id: 1
Ключевой момент — первая строка вывода: задача появилась в anna.tasks сразу после присваивания, ещё до всякого commit. Никто не перечитывал базу: back_populates синхронизирует объекты в памяти. Ссылка на авторе и список у автора — это два взгляда на одну связь, и SQLAlchemy теперь держит их согласованными. На commit колонка user_id получила единицу — база записала ту же связь на своём языке.
Назначил автора одному концу связи — второй обновился в ту же секунду: коллекция и ссылка всегда согласованы. Проверим, что симметрия работает и в обратную сторону: добавим задачу в список Бориса и спросим у задачи, кто её автор.
from sqlalchemy import create_engine, ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task", back_populates="user", order_by="Task.id")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
user = relationship("User", back_populates="tasks")
def __repr__(self):
return f"Task({self.title!r})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
boris = User(name="Борис")
s.add(boris)
s.commit()
t = Task(title="багрепорт")
boris.tasks.append(t) # дописали в список - автор назначился
print("автор:", t.user.name)
print("user_id сразу:", t.user_id)
s.commit()
print("user_id после commit:", t.user_id)
автор: Борис user_id сразу: None user_id после commit: 1
Любопытная деталь в середине вывода: автор у задачи уже есть, а user_id пока None — колонка заполняется только при сохранении. Не путай два уровня: relationship-атрибуты живут в памяти и синхронизируются мгновенно, колонка — на языке базы — обновляется на flush и commit. Для кода это неважно: читай t.user и не думай о колонке вовсе; база получит своё при сохранении.
Как это устроено под капотом? SQLAlchemy навешивает на relationship-атрибуты события присваивания: в момент t.user = anna срабатывает обработчик, который лезет в парный атрибут и дополняет его список; при append в коллекцию — зеркальный обработчик назначает ссылку. Никакой магии по расписанию и никаких фоновых процессов: связь синхронизируется ровно в момент операции с атрибутом, синхронно и предсказуемо. Именно поэтому синхронизация работает и без базы — до первого flush объекты уже согласованы между собой.
Когда писать через ссылку, а когда через коллекцию? Ссылка короче, когда у тебя на руках задача и автор: одно присваивание вместо поиска в списке. Коллекция естественнее, когда автор — главный герой сцены: «добавим Пете ещё одну задачу» звучит как append. А конструкторы позволяют собрать связанный граф за один шаг с любой стороны — этим и воспользуемся в следующем примере: создадим автора сразу с задачами и задачу, привязанную к уже существующему автору, в одной сессии.
from sqlalchemy import create_engine, ForeignKey, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task", back_populates="user", order_by="Task.id")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
user = relationship("User", back_populates="tasks")
def __repr__(self):
return f"Task({self.title!r})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
olya = User(name="Оля", tasks=[Task(title="план"), Task(title="смета")])
t = Task(title="аудит", user=olya)
s.add_all([olya, t])
s.commit()
print("у Оли:", [x.title for x in olya.tasks])
print("автор аудита:", t.user.name)
with Session(engine) as s:
fresh = s.scalars(select(User).where(User.name == "Оля")).one()
print("в новой сессии:", [x.title for x in fresh.tasks])
у Оли: ['план', 'смета', 'аудит'] автор аудита: Оля в новой сессии: ['план', 'смета', 'аудит']
Два способа записи в одном блоке — конструктор с задачами у автора и ссылка у задачи — дали одинаковый результат: у Оли три задачи, причём третья примкнула к списку из середины. А перечитывание в новой сессии подтверждает: синхронизация в памяти не была иллюзией — в базу легло ровно то, что видели объекты. Порядок в списке держится order_by="Task.id", поэтому «аудит» встал в конец, хотя привязали его после создания.
В старом коде ты встретишь и другой параметр — backref: он делает то же самое, но объявляет встречный конец неявно, из одного места. Мы выбрали back_populates — современный стиль SQLAlchemy 2.0: оба relationship прописаны явно на обеих моделях, и связь видна целиком, откуда бы файл ни открыли. Явная пара чуть многословнее, зато не прячет половину объявления в чужом классе — при чтении моделей это дороже экономии двух строк. Есть и практический бонус: у Task гарантированно есть атрибут user, и подсказки редактора это видят, тогда как атрибут, созданный неявным backref, статические анализаторы часто не находят.
Перекладывание и отвязка
Синхронизация особенно заметна, когда связь меняется: задачу передают другому автору. Присваивание t.user = boris должно сделать две вещи сразу — добавить задачу Борису и убрать её у Анны. С back_populates обе происходят сами.
from sqlalchemy import create_engine, ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task", back_populates="user", order_by="Task.id")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
user = relationship("User", back_populates="tasks")
def __repr__(self):
return f"Task({self.title!r})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
boris = User(name="Борис")
s.add_all([anna, boris])
s.flush()
t1 = Task(title="отчёт", user=anna)
t2 = Task(title="багрепорт", user=boris)
s.add_all([t1, t2])
s.commit()
print("было:", [x.title for x in anna.tasks], "|", [x.title for x in boris.tasks])
t1.user = boris # перекладываем задачу Борису
print("у Анны:", [x.title for x in anna.tasks])
print("у Бориса:", [x.title for x in boris.tasks])
s.commit()
было: ['отчёт'] | ['багрепорт'] у Анны: [] у Бориса: ['багрепорт', 'отчёт']
Одна строка кода — а по сути полноценная бизнес-операция «передать задачу»: из списка Анны задача исчезла, в список Бориса легла, а на commit база получит UPDATE колонки user_id. Заодно в этом примере появился ещё один способ записи связи: конструктор Task(title="...", user=anna) принимает relationship-атрибут так же, как колонку. Меняй связь с любого конца — ссылка это или список — второй конец подтянется.
Симметричный вопрос: а как отвязать задачу от автора вовсе? Присвоить ссылке None — механика та же, только в обратную сторону: задача вылетит из списка автора, а колонка на сохранении получит NULL. Задача не удалится — она просто станет ничьей.
from sqlalchemy import create_engine, ForeignKey, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task", back_populates="user", order_by="Task.id")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
user = relationship("User", back_populates="tasks")
def __repr__(self):
return f"Task({self.title!r})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
s.add_all([anna, Task(title="отчёт", user=anna)])
s.commit()
t = s.scalars(select(Task).where(Task.title == "отчёт")).one()
print("было:", [x.title for x in anna.tasks])
t.user = None # отвязали задачу
print("после отвязки:", [x.title for x in anna.tasks])
print("автор:", t.user)
s.commit()
print("user_id:", t.user_id)
было: ['отчёт'] после отвязки: [] автор: None user_id: None
Обрати внимание: задача осталась в базе — изменился только автор, точнее его отсутствие. Список Анны опустел в ту же секунду, без перечитывания. Отвязка — та же операция, что и назначение, просто со значением None; и точно так же база узнаёт о ней только на commit.
from sqlalchemy import create_engine, ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task") # обратного конца нет
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
s.add(anna)
s.commit()
_ = anna.tasks # коллекция загружена в память
t = Task(title="отчёт")
t.user_id = anna.id # сырой внешний ключ, без relationship
s.add(t)
print("в памяти:", [x.title for x in anna.tasks])
s.commit()
print("после commit:", [x.title for x in anna.tasks])
в памяти: [] после commit: ['отчёт']
Дважды один и тот же вопрос — и два разных ответа. До commit коллекция уверена, что у Анны нет задач: она загружена в память и о ручной записи в колонку не слышала. Commit истёк объекты, обращение перечитало связь из базы — и задача нашлась. В маленьком скрипте это переживается легко, а в живом приложении именно так выглядят «задача есть, а в интерфейсе у автора её нет» — классический симптом связи, которую пишут мимо relationship.
Пиши через объект — читай с двух сторон
Соберём урок в одну привычку: связь записываем только через объектные атрибуты — t.user = кто-то или список.append(задача), — а читаем с любой стороны. Колонку user_id оставим SQLAlchemy: она по-прежнему есть в базе, но в коде приложения её трогать не нужно.
from sqlalchemy import create_engine, ForeignKey, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task", back_populates="user", order_by="Task.id")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
user = relationship("User", back_populates="tasks")
def __repr__(self):
return f"Task({self.title!r})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
boris = User(name="Борис")
s.add_all([anna, boris])
s.flush()
s.add_all([
Task(title="отчёт", user=anna),
Task(title="аудит", user=anna),
Task(title="презентация", user=boris),
])
s.commit()
with Session(engine) as s:
t = s.scalars(select(Task).where(Task.title == "отчёт")).one()
print(t.title, "-", t.user.name)
u = s.scalars(select(User).where(User.name == "Анна")).one()
print(u.name, "-", [x.title for x in u.tasks])
отчёт - Анна Анна - ['отчёт', 'аудит']
| Действие | Что видит второй конец | Что получит база |
|---|---|---|
| t.user = anna | список anna.tasks обновится сразу | UPDATE user_id при commit |
| anna.tasks.append(t) | t.user назначится сразу | UPDATE user_id при commit |
| t.user_id = anna.id | ничего не увидит до перечитывания | UPDATE user_id при commit |
# ТАК НЕЛЬЗЯ: tasks объявлен с back_populates, но атрибута user на Task нет
tasks = relationship("Task", back_populates="user") # а user не существует!
# SQLAlchemy поднимет ошибку при настройке маппера, до первого запроса:
# back_populates ссылается на несуществующий атрибут
# ПРАВИЛЬНО: зеркальная пара на обеих моделях
class User(Base):
tasks = relationship("Task", back_populates="user")
class Task(Base):
user = relationship("User", back_populates="tasks")
Что дальше
Теперь связь симметрична и честна: task.user и user.tasks — два конца одной связи, синхронизированные в памяти и хранимые одной колонкой в базе. Код приложения окончательно перестал упоминать user_id: числа остались языком базы, объекты — языком логики. Навигация отвечает на вопрос «покажи задачи Анны», когда Анна уже на руках.
Но запросы бывают и обратные: «найди все задачи, у которых автор — Анна», при том что Анна не загружена, а известна по имени. Можно достать её select-ом по имени и пойти навигацией — два запроса там, где хватает одного. Фильтрация по связанной таблице решается join-ом — прямым наследником тех самых SQL-соединений, которые ORM держала за кадром: одна выборка сразу соединяет таблицы и фильтрует по чужим колонкам. Встречаем его в уроке 11.
back_populates делает связь честной в обе стороны: изменил один конец — второй узнал немедленно. Сырая колонка меняет базу, объектная связь меняет модель.
Сначала предскажи ответ в голове — это главный навык программиста.
from sqlalchemy import create_engine, ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task", back_populates="user", order_by="Task.id")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
user = relationship("User", back_populates="tasks")
def __repr__(self):
return f"Task({self.title!r})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
t = Task(title="отчёт")
s.add_all([anna, t])
s.commit()
t.user = anna
print(len(anna.tasks), t.user.name)
from sqlalchemy import create_engine, ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
s.add(anna)
s.commit()
_ = anna.tasks
t = Task(title="аудит")
t.user_id = anna.id
s.add(t)
print(len(anna.tasks))
s.commit()
print(len(anna.tasks))
1. Что даёт параметр back_populates в relationship?
2. После t.user = anna что вернёт anna.tasks до всякого commit?
3. Почему после boris.tasks.append(t) колонка t.user_id ещё None?
4. Что произойдёт при t.user_id = anna.id, если коллекция anna.tasks уже загружена?
5. Как объявить двустороннюю связь правильно?
Объяви двустороннюю связь: User с tasks = relationship("Task", back_populates="user", order_by="Task.id") и Task с user = relationship("User", back_populates="tasks"). Создай двух авторов — Олю и Плю — и задачу «смета»; назначь ей автором Олю через t.user, сохрани, затем передай задачу Пете тем же присваиванием. Выведи: список задач Оли, список задач Пети и имя автора задачи.
Что такое back_populates в SQLAlchemy?
Зеркальный параметр, объявляющий два relationship двумя концами одной связи: user.tasks и task.user. Благодаря ему SQLAlchemy синхронизирует концы в памяти: присваивание task.user немедленно пополняет user.tasks и наоборот, а база получает одну и ту же колонку user_id при commit.
Чем back_populates отличается от backref?
Оба делают связь двусторонней, но backref объявляет встречный конец неявно из одного места, а back_populates требует явно описать оба relationship на обеих моделях. В коде на SQLAlchemy 2.0 приняты явные back_populates: оба конца видно прямо в моделях, читать и править такой код проще.
Почему task.user_id заполнен, а user.tasks пуст?
Так проявляется связь, записанная мимо relationship: сырая запись в колонку не уведомляет загруженную коллекцию. Она перечитается только после commit, который истекает объекты. Записывай связь объектами — t.user = автор или автор.tasks.append(t) — и оба конца будут согласованы сразу.
Нужна ли колонка ForeignKey, если есть relationship с back_populates?
Да: relationship — только Python-уровень навигации, хранение связи остаётся на колонке ForeignKey в дочерней таблице. Убери колонку — и SQLAlchemy не сможет построить связь: мапперу нужен путь из внешнего ключа, а объектам — удобные атрибуты поверх него.
Понравился урок? Сошлитесь на него
«Назначил автора одному концу связи — второй обновился в ту же секунду: коллекция и ссылка всегда согласованы.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
SQLAlchemy · Урок 9
Связи один-ко-многим: ForeignKey и relationship
Одна таблица — это заметка, две связанные — база: ForeignKey держит ссылку, relationship превращает её в навигацию по объектам.
SQLAlchemy · Урок 11
Join в ORM: запросы по связям
Навигация читает связь у готового объекта, join фильтрует по чужой таблице в одном запросе: все задачи автора по имени.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Matplotlib / Seaborn · Урок 4
Диаграммы рассеяния scatter: ищем связи между величинами
scatter для поиска связей между величинами: форма облака точек, цвет и размер как третье измерение, alpha против оверплоттинга и кейс с 300 измерениями.
связь между величинами график python
asyncio · Урок 6
Задачи: asyncio.create_task
Одна строка — и два await из прошлого урока бегут одновременно: 4 секунды становятся 2. Знакомимся с задачами, их done() и result() и правилом о ссылке на задачу.
asyncio tasktask done result python
SQLAlchemy · Урок 20
Проект: менеджер задач на ORM с тестами
Финал курса: собираем менеджер задач — модели со связью, CRUD, доска и тест-сьют, который проверяет всё это шестью зелёными тестами.
sqlalchemy проектменеджер задач sqlalchemy