Typorithm · easy

Foreign keys, relationships and parent ownership

pythonsqlalchemyalembicdatabasesindependent-practice
Saved on this device
Full lesson · 4 learning steps Open any exercise for focused practice
1

Step 1 of 4 · intro card

Foreign keys, relationships and parent ownership

0%

Concept and contract

Verify relational integrity at the database boundary instead of mistaking an ORM link for an enforced constraint.

A relationship describes how ORM objects navigate to one another; a foreign key describes a relational constraint. They cooperate, but they are not interchangeable. A Python object graph can look coherent while a database connection accepts an orphan row if its constraint enforcement is disabled.

The fixture creates employers and their role collections, including employers with zero roles. SQLite needs foreign-key enforcement enabled on every connection before use. Because the owned driver uses autocommit=False, its connect hook briefly changes that setting while issuing the PRAGMA and then restores the original transaction policy.

Mechanism and worked example

The example adds each Employer with a supplied number of Role children and reads the resulting collection sizes.

The example adds each Employer with a supplied number of Role children and reads the resulting collection sizes. It then opens a separate Core transaction and deliberately tries to insert a role referencing employer 9999, which the fixture has never created. The expected IntegrityError proves the database rejected that orphan.

For counts [2, 0, 1], the report retains all three employers and says orphanRejected is True. back_populates gives the two mapped attributes a matching relationship; it does not itself execute the PRAGMA or replace the database foreign key. The source keeps those responsibilities explicit.

Complete worked example

python

from sqlalchemy import create_engine, event, ForeignKey, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session

class Base(DeclarativeBase):
    pass

class Employer(Base):
    __tablename__ = "employers"
    id: Mapped[int] = mapped_column(primary_key=True)
    roles: Mapped[list["Role"]] = relationship(back_populates="employer")

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    employer_id: Mapped[int] = mapped_column(ForeignKey("employers.id", ondelete="RESTRICT"))
    employer: Mapped[Employer] = relationship(back_populates="roles")

from sqlalchemy import insert
from sqlalchemy.exc import IntegrityError

def inspect_example(counts):
    engine = create_engine("sqlite+pysqlite://", connect_args={"autocommit": False})
    @event.listens_for(engine, "connect")
    def enable_foreign_keys(connection, record):
        previous = connection.autocommit
        connection.autocommit = True
        try:
            connection.execute("PRAGMA foreign_keys=ON").close()
        finally:
            connection.autocommit = previous
    try:
        Base.metadata.create_all(engine)
        with Session(engine) as session, session.begin():
            for i, count in enumerate(counts, 1):
                session.add(Employer(id=i, roles=[Role() for _ in range(count)]))
        with Session(engine) as session:
            observed = [len(e.roles) for e in session.scalars(select(Employer).order_by(Employer.id))]
        rejected = False
        try:
            with engine.begin() as connection:
                connection.execute(insert(Role).values(id=9999, employer_id=9999))
        except IntegrityError:
            rejected = True
        return {"counts": observed, "orphanRejected": rejected}
    finally:
        engine.dispose()
Reasoning
The example adds each Employer with a supplied number of Role children and reads the resulting collection sizes. It then opens a separate Core transaction and deliberately tries to insert a role referencing employer 9999, which the fixture has never created. The expected IntegrityError proves the database rejected that orphan. For counts [2, 0, 1], the report retains all three employers and says orphanRejected is True. back_populates gives the two mapped attributes a matching relationship; it does not itself execute the PRAGMA or replace the database foreign key. The source keeps those responsibilities explicit.
Download the SQLAlchemy and Alembic computer lab ↗

Focused decision: context is in the complete example

python

employer_id: Mapped[int] = mapped_column(ForeignKey("employers.id"))
employer: Mapped[Employer] = relationship(back_populates="roles")
Database decision
ForeignKey and relationship have different responsibilities. The connect hook enables database enforcement; the failed Core insert verifies that enforcement independently of ORM navigation.
Download the SQLAlchemy and Alembic computer lab ↗

Counterexamples and common mistakes

The controlled bug changes foreign_keys to OFF.

The controlled bug changes foreign_keys to OFF. The orphan insert succeeds even though the model still declares a ForeignKey and relationship. This demonstrates why reading model definitions is not evidence that a particular connection actually enforces the intended database rule.

Distinguish ORM cascades from database ON DELETE actions too. The fixture uses RESTRICT and tests a Core write, so the database itself must protect a referenced parent. An ORM delete may additionally update or remove children according to its configured cascade policy; those operations should be reviewed deliberately rather than inferred from a convenient object attribute.

Controlled bug: observe the rejected database contract

python

from sqlalchemy import create_engine, event, ForeignKey, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session

class Base(DeclarativeBase):
    pass

class Employer(Base):
    __tablename__ = "employers"
    id: Mapped[int] = mapped_column(primary_key=True)
    roles: Mapped[list["Role"]] = relationship(back_populates="employer")

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    employer_id: Mapped[int] = mapped_column(ForeignKey("employers.id", ondelete="RESTRICT"))
    employer: Mapped[Employer] = relationship(back_populates="roles")

from sqlalchemy import insert
from sqlalchemy.exc import IntegrityError

def inspect_example(counts):
    engine = create_engine("sqlite+pysqlite://", connect_args={"autocommit": False})
    @event.listens_for(engine, "connect")
    def enable_foreign_keys(connection, record):
        previous = connection.autocommit
        connection.autocommit = True
        try:
            connection.execute("PRAGMA foreign_keys=OFF").close()
        finally:
            connection.autocommit = previous
    try:
        Base.metadata.create_all(engine)
        with Session(engine) as session, session.begin():
            for i, count in enumerate(counts, 1):
                session.add(Employer(id=i, roles=[Role() for _ in range(count)]))
        with Session(engine) as session:
            observed = [len(e.roles) for e in session.scalars(select(Employer).order_by(Employer.id))]
        rejected = False
        try:
            with engine.begin() as connection:
                connection.execute(insert(Role).values(id=9999, employer_id=9999))
        except IntegrityError:
            rejected = True
        return {"counts": observed, "orphanRejected": rejected}
    finally:
        engine.dispose()
Reasoning
The controlled bug changes foreign_keys to OFF. The orphan insert succeeds even though the model still declares a ForeignKey and relationship. This demonstrates why reading model definitions is not evidence that a particular connection actually enforces the intended database rule. Distinguish ORM cascades from database ON DELETE actions too. The fixture uses RESTRICT and tests a Core write, so the database itself must protect a referenced parent. An ORM delete may additionally update or remove children according to its configured cascade policy; those operations should be reviewed deliberately rather than inferred from a convenient object attribute.
Download the SQLAlchemy and Alembic computer lab ↗

Independent practice

Start by typing the foreign-key declaration and the paired relationship name.

Start by typing the foreign-key declaration and the paired relationship name. The independent task reuses the existing protected-write pattern: attempt to delete the first employer through Core and report whether its existing roles prevented that deletion. Preserve the earlier orphan-insert check.

An employer with no roles can be deleted under this rule; one with roles must be protected. If there is no employer, do not invent a failed deletion. Keep the reported collection counts as observations taken before this destructive probe, and perform the experiment only inside the owned disposable database.

Step 1: Attempt to delete the first employer through Core and report whether its existing roles protected it. Step 2: Preserve the original orphan rejection and pre-probe counts.

Practice starter: solve before reading the solution

python

from sqlalchemy import create_engine, event, ForeignKey, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session

class Base(DeclarativeBase):
    pass

class Employer(Base):
    __tablename__ = "employers"
    id: Mapped[int] = mapped_column(primary_key=True)
    roles: Mapped[list["Role"]] = relationship(back_populates="employer")

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    employer_id: Mapped[int] = mapped_column(ForeignKey("employers.id", ondelete="RESTRICT"))
    employer: Mapped[Employer] = relationship(back_populates="roles")

from sqlalchemy import insert
from sqlalchemy.exc import IntegrityError

def inspect_example(counts):
    engine = create_engine("sqlite+pysqlite://", connect_args={"autocommit": False})
    @event.listens_for(engine, "connect")
    def enable_foreign_keys(connection, record):
        previous = connection.autocommit
        connection.autocommit = True
        try:
            connection.execute("PRAGMA foreign_keys=ON").close()
        finally:
            connection.autocommit = previous
    try:
        Base.metadata.create_all(engine)
        with Session(engine) as session, session.begin():
            for i, count in enumerate(counts, 1):
                session.add(Employer(id=i, roles=[Role() for _ in range(count)]))
        with Session(engine) as session:
            observed = [len(e.roles) for e in session.scalars(select(Employer).order_by(Employer.id))]
        rejected = False
        try:
            with engine.begin() as connection:
                connection.execute(insert(Role).values(id=9999, employer_id=9999))
        except IntegrityError:
            rejected = True
        return {"counts": observed, "orphanRejected": rejected}
    finally:
        engine.dispose()

# Task: Attempt to delete the first employer through Core and report whether its existing roles protected it.
Reasoning
Attempt to delete the first employer through Core and report whether its existing roles protected it.
Download the SQLAlchemy and Alembic computer lab ↗
  • Hint 1: Reuse a separate begin block with IntegrityError caught outside it.
  • Hint 2: Use delete(Employer).where(Employer.id == 1) and distinguish an empty employer.

Solution, tests and trade-offs

The solution catches IntegrityError from the attempted parent deletion and returns deleteProtected alongside the original observations.

The solution catches IntegrityError from the attempted parent deletion and returns deleteProtected alongside the original observations. The cases distinguish [2, 0, 1], a single empty employer and no employers at all. Thus neither always returning True nor treating every delete as forbidden can satisfy the contract.

The private fixture is discarded after verification. In an application, determine whether the domain wants restriction, reassignment or deletion of dependents, then align ORM cascade behavior and database actions. Test writes from outside the ORM as well, because another client or a migration can bypass a Python relationship entirely.

Practice solution and acceptance checks

python

from sqlalchemy import create_engine, event, ForeignKey, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session

class Base(DeclarativeBase):
    pass

class Employer(Base):
    __tablename__ = "employers"
    id: Mapped[int] = mapped_column(primary_key=True)
    roles: Mapped[list["Role"]] = relationship(back_populates="employer")

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    employer_id: Mapped[int] = mapped_column(ForeignKey("employers.id", ondelete="RESTRICT"))
    employer: Mapped[Employer] = relationship(back_populates="roles")

from sqlalchemy import insert, delete
from sqlalchemy.exc import IntegrityError

def inspect_example(counts):
    engine = create_engine("sqlite+pysqlite://", connect_args={"autocommit": False})
    @event.listens_for(engine, "connect")
    def enable_foreign_keys(connection, record):
        previous = connection.autocommit
        connection.autocommit = True
        try:
            connection.execute("PRAGMA foreign_keys=ON").close()
        finally:
            connection.autocommit = previous
    try:
        Base.metadata.create_all(engine)
        with Session(engine) as session, session.begin():
            for i, count in enumerate(counts, 1):
                session.add(Employer(id=i, roles=[Role() for _ in range(count)]))
        with Session(engine) as session:
            observed = [len(e.roles) for e in session.scalars(select(Employer).order_by(Employer.id))]
        rejected = False
        try:
            with engine.begin() as connection:
                connection.execute(insert(Role).values(id=9999, employer_id=9999))
        except IntegrityError:
            rejected = True
        protected = False
        if counts:
            try:
                with engine.begin() as connection:
                    connection.execute(delete(Employer).where(Employer.id == 1))
            except IntegrityError:
                protected = True
        return {"counts": observed, "orphanRejected": rejected, "deleteProtected": protected}
    finally:
        engine.dispose()
Reasoning
The solution catches IntegrityError from the attempted parent deletion and returns deleteProtected alongside the original observations. The cases distinguish [2, 0, 1], a single empty employer and no employers at all. Thus neither always returning True nor treating every delete as forbidden can satisfy the contract. The private fixture is discarded after verification. In an application, determine whether the domain wants restriction, reassignment or deletion of dependents, then align ORM cascade behavior and database actions. Test writes from outside the ORM as well, because another client or a migration can bypass a Python relationship entirely.
Download the SQLAlchemy and Alembic computer lab ↗

Interview reasoning and next step

Explain separately what ForeignKey, relationship, back_populates and an enforced SQLite PRAGMA contribute.

Explain separately what ForeignKey, relationship, back_populates and an enforced SQLite PRAGMA contribute. Predict a direct database write that would distinguish a real constraint from an object-only convention. State which layer protects the parent in this particular experiment and why an empty parent is different.

Next, examine the cost of traversing those collections. Correct ownership does not imply efficient loading: accessing one lazy collection per employer can emit many SELECT statements. The next lesson keeps the same parent population while measuring how an explicit loading strategy changes the number of database round trips.

Optional stretch — after the core task works: Compare a reviewed ORM cascade with the database RESTRICT action using another owned fixture.

Computer practice: https://applydjinn.com/typorithm/sqlalchemy-lab-20261107.zip. Install the pinned requirements in an isolated Python 3.13 environment and run verify.py. Edit the working exercise baseline, then run --practice sa-relationships. The lab uses disposable SQLite databases and actual SQLAlchemy/Alembic calls; it does not connect to application accounts, production databases or paid services. The app includes prepared results and explained questions without an editor or Python runtime.

Verify relational integrity at the database boundary instead of mistaking an ORM link for an enforced constraint.