Using `sqlalchemy.orm` with composite primary keys

Using sqlalchemy.orm I am trying to link two tables on a composite key, but keep getting an error. Unfortunately, the official docs provide an example that uses a single primary key (not composite), so I tried to come up with a basic example that reproduces the issue:

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


class Base(DeclarativeBase):
    pass


class Customer(Base):
    __tablename__ = "customer"
    id = mapped_column(String, primary_key=True)
    country = mapped_column(String, primary_key=True)

    billing_address_id = mapped_column(Integer, ForeignKey("address.idx"))
    shipping_address_id = mapped_column(Integer, ForeignKey("address.idx"))

    billing_address = relationship("Address", foreign_keys=[billing_address_id])
    shipping_address = relationship("Address", foreign_keys=[shipping_address_id])


class Address(Base):
    __tablename__ = "address"
    idx = mapped_column(Integer, primary_key=True)
    address = mapped_column(String)
    customer_id = mapped_column(ForeignKey("customer.id"))
    customer_country = mapped_column(ForeignKey("customer.country"))
    customers_using_this_adress = relationship(
        "Customer", foreign_keys=[customer_id, customer_country]
    )

# trying to define a customer triggers an error
c = Customer(id="A", country="B")

I get the following error:

AmbiguousForeignKeysError: Could not determine join condition between parent/child tables on relationship Address.customers_using_this_adress - there are multiple foreign key paths linking the tables.  Specify the 'foreign_keys' argument, providing a list of those columns which should be counted as containing a foreign key reference to the parent table.

Here’s the full traceback:

---------------------------------------------------------------------------
AmbiguousForeignKeysError                 Traceback (most recent call last)
File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/orm/relationships.py:2421, in JoinCondition._determine_joins(self)
   2420 if self.primaryjoin_initial is None:
-> 2421     self.primaryjoin = join_condition(
   2422         self.parent_persist_selectable,
   2423         self.child_persist_selectable,
   2424         a_subset=self.parent_local_selectable,
   2425         consider_as_foreign_keys=consider_as_foreign_keys,
   2426     )
   2427 else:

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/sql/util.py:123, in join_condition(a, b, a_subset, consider_as_foreign_keys)
    101 """Create a join condition between two tables or selectables.
    102 
    103 e.g.::
   (...)
    121 
    122 """
--> 123 return Join._join_condition(
    124     a,
    125     b,
    126     a_subset=a_subset,
    127     consider_as_foreign_keys=consider_as_foreign_keys,
    128 )

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/sql/selectable.py:1356, in Join._join_condition(cls, a, b, a_subset, consider_as_foreign_keys)
   1355 if len(constraints) > 1:
-> 1356     cls._joincond_trim_constraints(
   1357         a, b, constraints, consider_as_foreign_keys
   1358     )
   1360 if len(constraints) == 0:

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/sql/selectable.py:1501, in Join._joincond_trim_constraints(cls, a, b, constraints, consider_as_foreign_keys)
   1500 if len(constraints) != 1:
-> 1501     raise exc.AmbiguousForeignKeysError(
   1502         "Can't determine join between '%s' and '%s'; "
   1503         "tables have more than one foreign key "
   1504         "constraint relationship between them. "
   1505         "Please specify the 'onclause' of this "
   1506         "join explicitly." % (a.description, b.description)
   1507     )

AmbiguousForeignKeysError: Can't determine join between 'address' and 'customer'; tables have more than one foreign key constraint relationship between them. Please specify the 'onclause' of this join explicitly.

The above exception was the direct cause of the following exception:

AmbiguousForeignKeysError                 Traceback (most recent call last)
Cell In[11], line 32
     26     customer_country = mapped_column(ForeignKey("customer.country"))
     27     customers_using_this_adress = relationship(
     28         "Customer", foreign_keys=[customer_id, customer_country]
     29     )
---> 32 c = Customer(id="A", country="B")

File <string>:4, in __init__(self, **kwargs)

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/orm/state.py:570, in InstanceState._initialize_instance(*mixed, **kwargs)
    567 self, instance, args = mixed[0], mixed[1], mixed[2:]  # noqa
    568 manager = self.manager
--> 570 manager.dispatch.init(self, args, kwargs)
    572 try:
    573     manager.original_init(*mixed[1:], **kwargs)

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/event/attr.py:487, in _CompoundListener.__call__(self, *args, **kw)
    485     fn(*args, **kw)
    486 for fn in self.listeners:
--> 487     fn(*args, **kw)

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/orm/mapper.py:4308, in _event_on_init(state, args, kwargs)
   4306 instrumenting_mapper = state.manager.mapper
   4307 if instrumenting_mapper:
-> 4308     instrumenting_mapper._check_configure()
   4309     if instrumenting_mapper._set_polymorphic_identity:
   4310         instrumenting_mapper._set_polymorphic_identity(state)

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/orm/mapper.py:2374, in Mapper._check_configure(self)
   2366 @util.langhelpers.tag_method_for_warnings(
   2367     "This warning originated from the `configure_mappers()` process, "
   2368     "which was invoked automatically in response to a user-initiated "
   (...)
   2371 )
   2372 def _check_configure(self) -> None:
   2373     if self.registry._new_mappers:
-> 2374         _configure_registries({self.registry}, cascade=True)

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/orm/mapper.py:4116, in _configure_registries(registries, cascade)
   4110     Mapper.dispatch._for_class(Mapper).before_configured()  # type: ignore # noqa: E501
   4111     # initialize properties on all mappers
   4112     # note that _mapper_registry is unordered, which
   4113     # may randomly conceal/reveal issues related to
   4114     # the order of mapper compilation
-> 4116     _do_configure_registries(registries, cascade)
   4117 finally:
   4118     _already_compiling = False

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/orm/mapper.py:4158, in _do_configure_registries(registries, cascade)
   4156 if not mapper.configured:
   4157     try:
-> 4158         mapper._post_configure_properties()
   4159         mapper._expire_memoizations()
   4160         mapper.dispatch.mapper_configured(mapper, mapper.class_)

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/orm/mapper.py:2391, in Mapper._post_configure_properties(self)
   2388 self._log("initialize prop %s", key)
   2390 if prop.parent is self and not prop._configure_started:
-> 2391     prop.init()
   2393 if prop._configure_finished:
   2394     prop.post_instrument_class(self)

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/orm/interfaces.py:544, in MapperProperty.init(self)
    537 """Called after all mappers are created to assemble
    538 relationships between mappers and perform other post-mapper-creation
    539 initialization steps.
    540 
    541 
    542 """
    543 self._configure_started = True
--> 544 self.do_init()
    545 self._configure_finished = True

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/orm/relationships.py:1634, in RelationshipProperty.do_init(self)
   1632 self._setup_entity()
   1633 self._setup_registry_dependencies()
-> 1634 self._setup_join_conditions()
   1635 self._check_cascade_settings(self._cascade)
   1636 self._post_init()

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/orm/relationships.py:1881, in RelationshipProperty._setup_join_conditions(self)
   1880 def _setup_join_conditions(self) -> None:
-> 1881     self._join_condition = jc = JoinCondition(
   1882         parent_persist_selectable=self.parent.persist_selectable,
   1883         child_persist_selectable=self.entity.persist_selectable,
   1884         parent_local_selectable=self.parent.local_table,
   1885         child_local_selectable=self.entity.local_table,
   1886         primaryjoin=self._init_args.primaryjoin.resolved,
   1887         secondary=self._init_args.secondary.resolved,
   1888         secondaryjoin=self._init_args.secondaryjoin.resolved,
   1889         parent_equivalents=self.parent._equivalent_columns,
   1890         child_equivalents=self.mapper._equivalent_columns,
   1891         consider_as_foreign_keys=self._user_defined_foreign_keys,
   1892         local_remote_pairs=self.local_remote_pairs,
   1893         remote_side=self.remote_side,
   1894         self_referential=self._is_self_referential,
   1895         prop=self,
   1896         support_sync=not self.viewonly,
   1897         can_be_synced_fn=self._columns_are_mapped,
   1898     )
   1899     self.primaryjoin = jc.primaryjoin
   1900     self.secondaryjoin = jc.secondaryjoin

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/orm/relationships.py:2308, in JoinCondition.__init__(self, parent_persist_selectable, child_persist_selectable, parent_local_selectable, child_local_selectable, primaryjoin, secondary, secondaryjoin, parent_equivalents, child_equivalents, consider_as_foreign_keys, local_remote_pairs, remote_side, self_referential, prop, support_sync, can_be_synced_fn)
   2305 self.support_sync = support_sync
   2306 self.can_be_synced_fn = can_be_synced_fn
-> 2308 self._determine_joins()
   2309 assert self.primaryjoin is not None
   2311 self._sanitize_joins()

File ~/miniconda3/envs/excel/lib/python3.11/site-packages/sqlalchemy/orm/relationships.py:2465, in JoinCondition._determine_joins(self)
   2453     raise sa_exc.AmbiguousForeignKeysError(
   2454         "Could not determine join "
   2455         "condition between parent/child tables on "
   (...)
   2462         "parent and child tables." % (self.prop, self.secondary)
   2463     ) from afe
   2464 else:
-> 2465     raise sa_exc.AmbiguousForeignKeysError(
   2466         "Could not determine join "
   2467         "condition between parent/child tables on "
   2468         "relationship %s - there are multiple foreign key "
   2469         "paths linking the tables.  Specify the "
   2470         "'foreign_keys' argument, providing a list of those "
   2471         "columns which should be counted as containing a "
   2472         "foreign key reference to the parent table." % self.prop
   2473     ) from afe

AmbiguousForeignKeysError: Could not determine join condition between parent/child tables on relationship Address.customers_using_this_adress - there are multiple foreign key paths linking the tables.  Specify the 'foreign_keys' argument, providing a list of those columns which should be counted as containing a foreign key reference to the parent table.

>Solution :

Unlike a composite primary key where you can add primary_key=True to columns you want, composite foreign keys do not work that way. What you have right now is two different foreign keys and not a single composite foreign key. You need to use ForeignKeyConstraint in the __table_args__ tuple like the following.

    __table_args__ = (
        ForeignKeyConstraint(
            [customer_id, customer_country], [Customer.id, Customer.country]
        ),
    )

Full code

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


class Base(DeclarativeBase):
    pass


class Customer(Base):
    __tablename__ = "customer"
    id = mapped_column(String, primary_key=True)
    country = mapped_column(String, primary_key=True)

    billing_address_id = mapped_column(Integer, ForeignKey("address.idx"))
    shipping_address_id = mapped_column(Integer, ForeignKey("address.idx"))

    billing_address = relationship("Address", foreign_keys=[billing_address_id])
    shipping_address = relationship("Address", foreign_keys=[shipping_address_id])


class Address(Base):
    __tablename__ = "address"
    idx = mapped_column(Integer, primary_key=True)
    address = mapped_column(String)
    customer_id = mapped_column(Integer)
    customer_country = mapped_column(Integer)
    customers_using_this_adress = relationship(
        "Customer", foreign_keys=[customer_id, customer_country]
    )
    __table_args__ = (
        ForeignKeyConstraint(
            [customer_id, customer_country], [Customer.id, Customer.country]
        ),
    )

c = Customer(id="A", country="B")

Leave a Reply