How-To: Work with Foreign Keys Using IDSchema#

Use fr.IDSchema[Model] as a schema field type when you want clients to reference a related object by ID, and have FastAPI-Restly resolve it to a real SQLAlchemy instance automatically.

Naming Convention#

Two requirements must be met for automatic resolution to work:

  1. The schema field name must end in _id (for example, author_id).

  2. The SQLAlchemy model must have a relationship attribute with the same name minus the _id suffix (for example, author).

When both conditions are met, the view sets both the FK column (author_id) and the relationship attribute (author) on the new or updated object. If the relationship attribute is absent, only the FK column is set.

Model Setup#

import fastapi_restly as fr
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import ForeignKey

class Author(fr.IDBase):
    name: Mapped[str]

class Article(fr.IDBase):
    title: Mapped[str]
    author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
    author: Mapped["Author"] = relationship(default=None, init=False)

fr.IDBase is the convenience alias built on fr.DataclassBase, so it still auto-generates the table name from the class name (Authorauthor, Articlearticle). That is why ForeignKey("author.id") is correct here.

Why init=False and default=None are required#

fr.IDBase uses SQLAlchemy’s MappedAsDataclass, which auto-generates an __init__ from the model’s field declarations. Any attribute that is not marked init=False becomes a constructor parameter.

Relationship attributes should not be constructor parameters — SQLAlchemy loads them lazily from the database via the foreign key column. If you omit init=False, SQLAlchemy will expect the related object to be passed directly to Article(...), which is not how FK-based construction works.

default=None is the companion requirement: without a default value, the generated __init__ would require the relationship as a positional argument, making it impossible to construct the object at all.

The correct declaration is always:

author: Mapped["Author"] = relationship(default=None, init=False)

Plain (non-dataclass) models#

If you use fr.PlainBase / fr.PlainIDBase instead of fr.IDBase, the dataclass constraint does not apply. Plain models use SQLAlchemy’s traditional declarative style and accept any keyword arguments in __init__, so init=False is not needed:

class Article(fr.PlainIDBase):
    __tablename__ = "article"
    title: Mapped[str]
    author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
    author: Mapped["Author"] = relationship()  # no init=False needed

Schema Setup#

import fastapi_restly as fr

class AuthorSchema(fr.IDSchema):
    name: str

class ArticleSchema(fr.IDSchema):
    title: str
    author_id: fr.IDSchema[Author]

View Setup#

@fr.include_view(app)
class ArticleView(fr.AsyncRestView):
    prefix = "/articles"
    model = Article
    schema = ArticleSchema

Request Format#

The client sends the related object’s primary key wrapped in an object:

{
  "title": "Intro",
  "author_id": {"id": 1}
}

The view looks up the Author with id=1 and raises 404 if it does not exist.

Behavior#

  • The id inside the {"id": 1} payload is the foreign key value provided by the client, not the article’s own primary key.

  • FastAPI-Restly resolves author_id to an Author ORM instance before creating or updating the object.

  • Both the FK column (author_id) and the relationship (author) are kept in sync on write, provided the author relationship exists on the model.

  • On dataclass-based models, the framework detects init=False on the relationship attribute and skips passing it to __init__. The FK column is still set, so SQLAlchemy will populate the relationship on the next access.

  • Missing related IDs return 404.