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
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
Resolving Warnings, Errors & Common Mistakes
| Error / Warning | Root Cause | Production Fix |
|---|---|---|
TypeError: non-default argument 'status' follows default argument at import | A 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 allowed | A mutable default= where default_factory= is required. | default_factory=list for collections, default_factory=dict for keyed ones. |
MissingGreenlet from a log line | The 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 flush | A relationship with no default_factory, so the attribute was never initialised. | default_factory=list. |
| Two instances that should differ compare equal | The 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.
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.
Related
- Typed Declarative Models and Mapped Annotations — The parent guide: how the same annotations that generate these constructors resolve to SQL types.
- Typing relationships with Mappedlist... — Why a relationship field needs default_factory, and what repr=False prevents.
- Fixing GreenletSpawnError in async SQLAlchemy workflows — Why a generated repr that reads a relationship raises under async.
- Using mapped_column instead of Column — The init, default and repr arguments in the context of every other mapped_column keyword.