Using association objects for many-to-many with extra columns

When the link between two entities has data of its own — a quantity, a role, a joined-at date — map the link table as its own class with two ForeignKey columns and a relationship() to each side, instead of relationship(secondary=...), and add an association_proxy where the far side is all the caller wants. This guide belongs to modeling relationships, cascades and association objects.

Quick Answer

A secondary table can only ever hold the two foreign keys. The moment the link needs a column, it has to become a class.

When secondary stops being enough Left: relationship with secondary names a link table SQLAlchemy manages invisibly; it can only hold the two foreign keys, so there is nowhere to record how many of a product an order contains. Right: the link table is mapped as its own class with quantity and unit price, and each side has a one-to-many relationship to it. secondary="order_products" two foreign keys only rows created and deleted for you no room for quantity or price right when the link is just a link class OrderItem(Base) order_id, product_id + columns quantity, unit_price_cents, added_at two one-to-many relationships the link is a thing in its own right The question to ask: would you ever want to store a column on this link? If yes, map it now.

Before — a secondary table with nowhere to put the quantity:

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


class Base(DeclarativeBase):
    pass


order_products = Table(
    "order_products",
    Base.metadata,
    Column("order_id", ForeignKey("orders.id"), primary_key=True),
    Column("product_id", ForeignKey("products.id"), primary_key=True),
)


class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    products: Mapped[list["Product"]] = relationship(secondary=order_products)
    # Where does "three of this product, at the price on the day" go?

After — the link mapped as a class, with a proxy for convenience:

from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


class OrderItem(Base):
    __tablename__ = "order_items"
    __table_args__ = (UniqueConstraint("order_id", "product_id"),)

    id: Mapped[int] = mapped_column(primary_key=True)
    order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE"))
    product_id: Mapped[int] = mapped_column(ForeignKey("products.id"))
    quantity: Mapped[int] = mapped_column(default=1)
    unit_price_cents: Mapped[int]

    order: Mapped["Order"] = relationship(back_populates="items")
    product: Mapped["Product"] = relationship(back_populates="order_items")


class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    items: Mapped[list[OrderItem]] = relationship(
        back_populates="order", cascade="all, delete-orphan", passive_deletes=True
    )
    products: AssociationProxy[list["Product"]] = association_proxy("items", "product")


class Product(Base):
    __tablename__ = "products"
    id: Mapped[int] = mapped_column(primary_key=True)
    sku: Mapped[str]
    order_items: Mapped[list[OrderItem]] = relationship(back_populates="product")

order.items now gives the links with their data, and order.products still gives the products directly, so callers that never cared about quantity are unaffected.

Execution Context & Async Workflow Integration

An association object is not a special construct — it is an ordinary mapped class that happens to sit between two others, with a one-to-many relationship from each side into it. Everything you know about relationships applies unchanged, which is exactly why it scales better than secondary as requirements grow.

Saving through an association object Four steps. Build the association object with its own data, such as quantity, and set its product. Append it to order.items, which associates it with the order. At flush, SQLAlchemy inserts the order first, then the association row, filling both foreign keys from the related objects. Reading order.items back gives association objects, and each one carries its product. OrderItem(product=product, quantity=3) the link, with its data no ids needed yet order.items.append(item) associates it with the order cascade saves it flush INSERT orders, then order_items both foreign keys filled in order.items[0].product the far side, one hop away association_proxy adds order.products for the cases where the link data is not needed.

Writing goes through the association object. Because Order.items cascades save-update, appending an unsaved OrderItem is enough; SQLAlchemy inserts the order, then the item, filling order_id and product_id from the related objects, so no primary key has to be known in advance:

from shop.models import Order, OrderItem


async def add_line(session, order: Order, product, quantity: int) -> None:
    order.items.append(
        OrderItem(product=product, quantity=quantity, unit_price_cents=product.price_cents)
    )
    await session.commit()

Capturing unit_price_cents on the link is the point of the whole exercise: the price the customer paid belongs to the order, not to the product, and a secondary table has nowhere to keep it.

The association_proxy is a read-and-write convenience over the same data. Appending to order.products creates an OrderItem with only its product set, which works when every other column has a default and fails when one does not — so for models like this one, treat the proxy as read-only and write through order.items. A creator= callable makes the write path explicit when it is wanted: association_proxy("items", "product", creator=lambda p: OrderItem(product=p, unit_price_cents=p.price_cents)).

Under async, the thing to get right is loading depth. order.items is one relationship and item.product is another, so reading a product through an order is two levels, and both need loader options or the second raises MissingGreenlet. Chained loaders express that directly:

from sqlalchemy import select
from sqlalchemy.orm import selectinload

from shop.models import Order, OrderItem

stmt = (
    select(Order)
    .where(Order.id == order_id)
    .options(selectinload(Order.items).selectinload(OrderItem.product))
)
order = await session.scalar(stmt)

selectinload here costs two extra queries regardless of how many orders or items are involved, which is why it is the usual choice for collections; the trade-off against joinedload is covered in using selectinload vs joinedload for N+1 prevention. The association proxy follows whatever the underlying relationships loaded, so order.products is safe only once both levels are in memory.

Resolving Warnings, Errors & Common Mistakes

Exact error or warningRoot CauseProduction Fix
MissingGreenlet: greenlet_spawn has not been called reading item.productOnly the first level was eagerly loaded.Chain the loaders: selectinload(Order.items).selectinload(OrderItem.product).
SAWarning: relationship 'Order.products' will copy column orders.id to column order_items.order_id, which conflicts with relationship(s) 'Order.items'A secondary relationship kept alongside the mapped association object, both writable.viewonly=True on the secondary one, or remove it and use a proxy.
IntegrityError: duplicate key value violates unique constraint "uq_order_items_order_id_product_id"The same product appended twice to one order.Decide the rule: merge quantities in the service layer, or drop the unique constraint.
IntegrityError: null value in column "unit_price_cents" when appending to the proxyThe proxy created an OrderItem with only product set.Write through order.items, or give the proxy a creator=.
InvalidRequestError: Mapper could not assemble any primary key columnsThe link class has foreign keys but no primary key.A surrogate id, or primary_key=True on both foreign keys.
Removing an item from order.items leaves the rowNo delete-orphan on the collection.cascade="all, delete-orphan".
AmbiguousForeignKeysError on a self-referential linkTwo foreign keys to the same table; SQLAlchemy cannot guess which is which.Spell out foreign_keys= on each relationship.
Reading across the link Three tiles. order.items returns the association objects themselves, each carrying quantity and a product. The association proxy order.products returns the far-side objects directly, skipping the link. A query joining both tables returns rows of columns from either side, which is what a report wants. order.items → [OrderItem, ...] quantity, unit price, product order.products (proxy) → [Product, ...] convenience, no link data select(Order, OrderItem, Product) → rows of columns for reports and exports Under async, each of these needs its loader options set: two levels means two chained loads.

The "will copy column" warning is the most common one in this shape, and it is a genuine conflict rather than noise: two relationships both claim the right to write order_items.order_id, and which one wins depends on flush order. It gets a guide of its own — fixing "relationship will copy column" conflicts — because the same message appears in several other mappings.

The duplicate-key row is a design question disguised as an error. A unique constraint on (order_id, product_id) says a product appears at most once per order, so adding it again must mean "increase the quantity". Encode that in one place:

from sqlalchemy import select

from shop.models import OrderItem


async def add_or_merge_line(session, order, product, quantity: int) -> OrderItem:
    for item in order.items:                 # already loaded by the caller
        if item.product_id == product.id:
            item.quantity += quantity
            return item
    item = OrderItem(product=product, quantity=quantity,
                     unit_price_cents=product.price_cents)
    order.items.append(item)
    return item

Under concurrency this still races, and the resolution is the same as for any unique key: let the database decide, and handle the conflict — see handling IntegrityError on concurrent inserts.

Reports rarely want objects. When the question is "how many units of each product did we ship last month", the association table is just a table to join, and the ORM's job is to name the columns:

The four settings that matter Four bands. A composite primary key, or a unique constraint, on the two foreign keys stops duplicate links. cascade all with delete-orphan on both parent collections means removing a link deletes its row. viewonly on any direct relationship that also spans the link keeps two mappings from writing the same columns. And lazy raise on the link relationships makes accidental lazy loading an error rather than a MissingGreenlet at the wrong moment. primary key or unique on (order_id, product_id) one link per pair — the database enforces what the model assumes cascade="all, delete-orphan" on both collections removing an item from order.items deletes the row rather than orphaning it viewonly=True on any secondary relationship kept alongside two writable mappings over the same columns is the "will copy column" warning lazy="raise" while migrating turns a forgotten loader option into an explicit error at development time
import datetime as dt

from sqlalchemy import func, select

from shop.models import Order, OrderItem, Product


def units_by_product(since: dt.date):
    return (
        select(
            Product.sku,
            func.sum(OrderItem.quantity).label("units"),
            func.sum(OrderItem.quantity * OrderItem.unit_price_cents).label("revenue_cents"),
        )
        .join(OrderItem, OrderItem.product_id == Product.id)
        .join(Order, Order.id == OrderItem.order_id)
        .where(Order.placed_on >= since, Order.status != "cancelled")
        .group_by(Product.sku)
        .order_by(func.sum(OrderItem.quantity).desc())
    )

Because unit_price_cents lives on the link, that revenue figure is what customers actually paid, which a join through a secondary table could not have produced at all.

Filtering parents by something about the link is the other common shape, and it wants any() rather than a join, so the parent rows are not duplicated:

from sqlalchemy import select

from shop.models import Order, OrderItem

# Orders containing more than five of any single product.
bulk_orders = select(Order).where(Order.items.any(OrderItem.quantity > 5))

# Orders that include a specific product at all.
with_product = select(Order).where(Order.items.any(OrderItem.product_id == product_id))

any() renders an EXISTS subquery, which returns each order once and can stop at the first matching item. A join would return one row per matching item and need distinct(), and combining it with eager loading of the same collection is what produces the cartesian-product warning described in fixing cartesian product warnings in SQLAlchemy joins.

When a filtered view of the collection is what you want — only the discounted items, say — contains_eager() with an explicit join lets the loaded collection be the filtered set rather than the whole one. That technique has its own guide in using contains_eager with filtered joins, and it comes with a warning worth repeating: the collection in memory then no longer represents every row in the database, so it must not be used to decide what to delete.

Migrating From a Secondary Table

Most association objects start life as a secondary table that outgrew itself. The migration is mechanical, and it can be done without downtime because the table itself does not change shape — only how the model sees it.

Two levels need two loads Left: selectinload of Order.items loads the association objects, and touching item.product then lazy-loads under async and raises MissingGreenlet. Right: selectinload of Order.items chained into selectinload of OrderItem.product issues two extra queries and leaves both levels loaded. selectinload(Order.items) items loaded item.product not loaded MissingGreenlet on first access one query too few selectinload(Order.items) .selectinload(OrderItem.product) both levels loaded two extra queries, constant in rows safe to serialise afterwards joinedload works too, and returns one wider row set; selectinload keeps the row count flat.

Step one: map the table as a class, without removing the old relationship. The class must describe the existing columns exactly, including the composite primary key if that is what the table has:

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


class OrderProduct(Base):
    __tablename__ = "order_products"          # the existing secondary table

    order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), primary_key=True)
    product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), primary_key=True)

    order: Mapped["Order"] = relationship(back_populates="product_links")
    product: Mapped["Product"] = relationship(back_populates="order_links")

Step two: mark the old relationship viewonly=True. Two writable mappings over the same columns is exactly the conflict SQLAlchemy warns about, and viewonly resolves it while leaving every existing reader working:

class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    products: Mapped[list["Product"]] = relationship(
        secondary="order_products", viewonly=True     # reads keep working
    )
    product_links: Mapped[list[OrderProduct]] = relationship(
        back_populates="order", cascade="all, delete-orphan"
    )

Step three: move the writers. Code that did order.products.append(product) becomes order.product_links.append(OrderProduct(product=product)). This is the only application change, and it can be done file by file while both mappings exist.

Step four: add the new columns. Only now does the schema change — a migration adding quantity and unit_price_cents, nullable at first, backfilled, then made NOT NULL if appropriate. The expand-and-contract sequence in adding a NOT NULL column without locking in Postgres applies unchanged.

Step five: replace the old relationship with an association_proxy, so order.products keeps working for readers without a second mapping of the same columns.

Doing it in this order means every deploy is compatible with the one before it, and the risky part — writers changing — happens while the schema is still exactly what it was.

Frequently Asked Questions

When should I use an association object instead of secondary?

As soon as the link has, or plausibly will have, any data of its own: a quantity, a role, a timestamp, a status. A secondary table is right only when the link is purely "these two are related".

Does association_proxy replace the relationship?

No, it reads through it. order.products is a view over order.items, so both levels must be loaded before it is used under async, and writes through it only work if the association object can be constructed from the far-side object alone.

Why do I get "will copy column ... conflicts with relationship" warnings?

Because two relationships both write the same foreign key columns — typically a secondary relationship kept alongside a mapped association object. Mark one viewonly=True.

How do I eager-load through an association object?

Chain the loaders: selectinload(Order.items).selectinload(OrderItem.product). Each relationship in the path needs its own loader option.