Generating model constructors with MappedAsDataclass

Inherit from MappedAsDataclass alongside DeclarativeBase and SQLAlchemy generates __init__, __repr__ and __eq__ from the same Mapped[] annotations that already define your columns — deleting the hand-written constructor most models carry, at the cost of dataclass field-ordering rules you now have to respect. This is the constructor-generation corner of typed declarative models and Mapped annotations.

Quick Answer

It generates the Python surface, not the schema Two columns. MappedAsDataclass generates __init__ with a keyword or positional parameter per field, __repr__ showing the field values, and __eq__ comparing them — all derived from the same annotations that define the columns. It changes nothing about the mapping itself: the table name, the columns, the constraints, the relationships and every query you write against the class are identical with and without it. The one thing it does change beyond the Python surface is that field order now matters, because a dataclass may not place a parameter without a default before one with a default. generated from the annotations __init__ with a parameter per field __repr__ showing the field values __eq__ comparing them and __hash__ when frozen unchanged table name, columns, constraints relationships and loader behaviour every query you already wrote but field ORDER now matters The generated __init__ replaces the hand-written one most models carry, which is where default values and required arguments quietly drift away from the column definitions.

Before — a hand-written constructor:

class Order(Base):
    __tablename__ = "orders"

    id: Mapped[int] = mapped_column(primary_key=True)
    reference: Mapped[str] = mapped_column(unique=True)
    total: Mapped[decimal.Decimal]
    note: Mapped[str | None]

    def __init__(self, reference: str, total: decimal.Decimal, note: str | None = None):
        self.reference = reference
        self.total = total
        self.note = note

    def __repr__(self) -> str:
        return f"<Order {self.reference!r} {self.total}>"

After — generated from the annotations:

from __future__ import annotations

import decimal

from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column


class Base(MappedAsDataclass, DeclarativeBase):
    """Every model in the project gets a generated constructor."""


class Order(Base):
    __tablename__ = "orders"

    # init=False: the database assigns it, so the caller must not pass one.
    id: Mapped[int] = mapped_column(primary_key=True, init=False)
    reference: Mapped[str] = mapped_column(unique=True)
    total: Mapped[decimal.Decimal]
    note: Mapped[str | None] = mapped_column(default=None)

Order(reference="ORD-1", total=Decimal("42.00")) now works, repr(order) prints every field, and two orders with equal fields compare equal. The constructor can no longer disagree with the column definitions, because there is only one definition.

Execution Context & Async Workflow Integration

Dataclass mapping is a class-construction feature and touches nothing at execution time, so it composes with AsyncSession without any special handling. The one interaction worth knowing about is __repr__: the generated representation reads every field, and a field that is a lazily-loaded relationship will try to load when it is read.

# Async — a generated __repr__ that includes a relationship is a hidden query
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship


class Customer(Base):
    __tablename__ = "customers"

    id: Mapped[int] = mapped_column(primary_key=True, init=False)
    email: Mapped[str] = mapped_column(unique=True)

    # repr=False — otherwise logging a Customer emits a query for its orders,
    # and under AsyncSession that raises MissingGreenlet instead.
    orders: Mapped[list[Order]] = relationship(
        back_populates="customer", default_factory=list, repr=False
    )

That single repr=False prevents a whole class of production incident: a log line, an exception handler, or a debugger inspecting a model triggers a lazy load from a context that cannot perform I/O. The same reasoning appears from the other direction in fixing GreenletSpawnError in async SQLAlchemy workflows — a __repr__ that touches a relationship is one of the four places that error reliably appears.

default_factory=list on that relationship is equally load-bearing. Without it, constructing a Customer leaves orders unset rather than empty, and appending to it before the instance is flushed fails.

# Async — constructing and persisting a generated-constructor model
async def create_customer(email: str) -> Customer:
    async with Session() as session, session.begin():
        customer = Customer(email=email)     # orders defaults to []
        customer.orders.append(Order(reference="ORD-1", total=decimal.Decimal("10")))
        session.add(customer)
    return customer
Four controls over the generated field Four arguments. init=False removes the field from __init__ entirely, which is what a server-generated primary key or a server_default timestamp needs — the caller must not be able to pass it. default gives the parameter a default value, which also moves it after every non-defaulted field in the signature. default_factory builds a new value per instance and is mandatory for anything mutable, including relationship collections, because a shared default list would be shared by every instance ever constructed. repr=False keeps a field out of the generated representation, which is what you want for a large text blob or an encrypted column. init=False not a constructor parameter for server-generated values primary keys, server_default default=... gives the parameter a default moves it after required fields ordering consequence default_factory=list a fresh value per instance mandatory for anything mutable collections especially repr=False kept out of __repr__ for blobs and secrets and for lazy relationships repr=False on a relationship is not cosmetic: the generated __repr__ reads every field, so a lazily-loaded relationship in the repr emits a query — or raises, under async.

Resolving Warnings, Errors & Common Mistakes

Error / WarningRoot CauseProduction Fix
TypeError: non-default argument 'status' follows default argument at importA field without a default declared after one with a default.Move it above every defaulted field, give it a default, or set kw_only=True on the class.
TypeError: __init__() got an unexpected keyword argument 'id'The primary key is a generated field and the caller passed one.mapped_column(primary_key=True, init=False) — the database assigns it.
ValueError: mutable default <class 'list'> for field orders is not allowedA mutable default= where default_factory= is required.default_factory=list for collections, default_factory=dict for keyed ones.
MissingGreenlet from a log lineThe generated __repr__ read a lazily-loaded relationship.repr=False on every relationship, and on large text or encrypted columns too.
AttributeError: 'Customer' object has no attribute 'orders' before flushA relationship with no default_factory, so the attribute was never initialised.default_factory=list.
Two instances that should differ compare equalThe generated __eq__ compares fields, and both instances have the same field values — including two unsaved rows whose primary keys are both None.Pass eq=False on the class if identity comparison is what you want, or make the primary key part of the comparison only after flush.

Advanced: Per-Field Control and Keyword-Only Models

Beyond the basics, the generated dataclass can be shaped field by field, and for models of any size kw_only=True is worth adopting as the project default.

from __future__ import annotations

import datetime
import decimal

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


class Base(MappedAsDataclass, DeclarativeBase, kw_only=True):
    """Keyword-only construction across the project.

    Removes field-ordering entirely as a design concern, and makes every call site
    self-documenting: Order(reference=..., total=...) rather than Order("ORD-1", 42).
    """


class Order(Base):
    __tablename__ = "orders"

    id: Mapped[int] = mapped_column(primary_key=True, init=False)
    reference: Mapped[str] = mapped_column(unique=True)
    customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"), index=True)
    total: Mapped[decimal.Decimal]

    # Server-assigned, so it must not be a constructor parameter — and it is not
    # populated in Python until the instance is refreshed after flush.
    created_at: Mapped[datetime.datetime] = mapped_column(
        server_default=func.now(), init=False
    )

    # Large, and uninteresting in a log line.
    payload: Mapped[str | None] = mapped_column(default=None, repr=False)

    customer: Mapped[Customer] = relationship(back_populates="orders", repr=False)

With kw_only=True, adding a required column anywhere in the class is a non-event: no reordering, no import-time TypeError, and every existing call site keeps working because it was already passing keywords. The cost is that positional construction is gone, which for a model with more than three fields was never a good idea anyway.

The init=False on created_at is worth dwelling on, because it exposes a genuine subtlety. The column has a server_default, so the database supplies the value — but the Python attribute is not populated until the instance is refreshed. Immediately after session.add() and flush(), order.created_at is None unless the flush returned it. In practice this means a handler that constructs an order and immediately serialises created_at gets None unless it refreshes, or unless the column is fetched via RETURNING, which SQLAlchemy does automatically for server defaults on backends that support it — PostgreSQL included. On a backend that does not, the refresh is explicit.

Frozen models are the other option worth knowing about. MappedAsDataclass accepts frozen=True, which generates __hash__ and blocks attribute assignment — and that makes the class unusable for the ORM's normal mutation-tracking flow, because the unit of work assigns to attributes during load. Reserve frozen=True for read-only projections that are never persisted.

Field ordering, and the import-time TypeError Four steps explaining the most common failure. The generated __init__ places parameters in attribute declaration order. A dataclass forbids a parameter without a default from following one that has a default, exactly as a Python function does. So adding a new required column in the middle of a class whose earlier fields have defaults raises TypeError at import time — before any database is touched, which at least makes it obvious. Two fixes work: move the new field above every defaulted field, or give it a default of its own. Setting kw_only=True on the class removes the constraint entirely by making every parameter keyword-only. the generated signature follows declaration order attribute order becomes API this is new a non-defaulted field may not follow a defaulted one the same rule as any Python function enforced by dataclasses TypeError at import time non-default argument follows default argument before any database work fix: reorder, add a default, or set kw_only=True kw_only removes the constraint entirely kw_only=True is the pragmatic default for models with more than a handful of columns: it makes construction self-documenting and removes ordering from the design entirely.

When Generated Constructors Are the Wrong Choice

MappedAsDataclass is not free of trade-offs, and there are shapes it fits badly.

A model with complex construction logic. If creating an entity involves validation, deriving one field from another, or normalising input, a generated constructor gives you nowhere to put that. The usual answer is a classmethod factory — Order.create(...) — that does the work and then calls the generated constructor, which keeps the two concerns apart. Trying to reintroduce a hand-written __init__ alongside the generated one does not work; the generation replaces it.

A model with many optional fields. The generated signature grows a parameter per column, and a wide table produces a constructor with thirty keyword arguments. That is not wrong, but it is a signal worth listening to: a class that needs thirty constructor parameters is usually two classes.

Inheritance hierarchies. Dataclass field ordering interacts with inheritance in ways that are correct but not obvious: fields from the base class come first, so a subclass cannot introduce a required field without a default unless the base's fields are all required too. kw_only=True on the base removes the problem entirely, and is close to mandatory for any polymorphic hierarchy.

Mixed adoption. A base class either generates constructors or it does not. If most of a project should keep hand-written constructors, adopt MappedAsDataclass on a second base rather than on the shared one — two bases in one registry is entirely supported, and is much less disruptive than converting everything at once.

The decision is worth making once, at the base class, and then not revisited per model. What you are choosing is whether the constructor is a place where behaviour lives, or a mechanical consequence of the column definitions. For most persistence models it should be the latter, which is precisely why the feature exists.

Frequently Asked Questions

Does MappedAsDataclass change the generated schema?

No. It affects __init__, __repr__, __eq__ and — with frozen=True__hash__. Table names, column types, constraints and indexes are unchanged. Converting a base class to MappedAsDataclass should produce an empty Alembic autogenerate diff, and that is worth verifying as part of the change.

Can I use it with only some of my models?

Yes. MappedAsDataclass is applied at the base class, and a single registry can hold more than one base. Declaring a second base for the models you want to convert lets adoption proceed gradually without touching the rest.

Why does my primary key show up as a required constructor argument?

Because every mapped attribute becomes a field unless told otherwise. A database-assigned primary key should carry init=False, which removes it from the signature; the same applies to any column with a server_default that the application never supplies.

Is kw_only=True worth setting by default?

For models with more than three or four columns, yes. It eliminates field-ordering as a design constraint, so adding a required column never breaks the class at import time, and it makes every construction site read as documentation. Positional construction of a persistence model is rarely clearer than the keyword form.