Skip to content

Bug: TypeError: Missing required argument when serializing DTOs with optional relationships using the experimental codegen backend #4504

@colinrsmall

Description

@colinrsmall

Description

Using a DTO (with the experimental codegen backend) to serialize a model's optional relationship field(s) when such a field is null leads to a TypeError: Missing required argument for the optional field.

This potentially only occurs when the model being serialized is provided by DI (see MCVE), as the following example which does not use DI to provide the model being serialized works fine with the experimental codegen:

from __future__ import annotations

from typing import TYPE_CHECKING, ClassVar

from sqlalchemy.ext.asyncio import AsyncSession

if TYPE_CHECKING:
    pass

import sqlalchemy as sa
from advanced_alchemy.extensions.litestar import (
    AsyncSessionConfig,
    SQLAlchemyAsyncConfig,
    SQLAlchemyDTO,
    SQLAlchemyDTOConfig,
    SQLAlchemyPlugin,
    base,
)
from litestar.app import Litestar
from litestar.handlers import get
from sqlalchemy.orm import mapped_column, relationship
from sqlalchemy.orm.base import Mapped

# ------------------------------------------------------------
# DB Config
# ------------------------------------------------------------

session_config = AsyncSessionConfig(expire_on_commit=False)
alchemy_config = SQLAlchemyAsyncConfig(
    connection_string="sqlite+aiosqlite:///:memory:",
    before_send_handler="autocommit",
    session_config=session_config,
    create_all=True,
)
alchemy = SQLAlchemyPlugin(config=alchemy_config)

# ------------------------------------------------------------
# User and Group models
# ------------------------------------------------------------

class User(base.BigIntBase):
    __tablename__ = "user"
    name: Mapped[str] = mapped_column(sa.String())
    group_id: Mapped[int | None] = mapped_column(sa.ForeignKey("group.id"), nullable=True)
    group: Mapped["GroupMembership | None"] = relationship(back_populates="user")

class GroupMembership(base.BigIntBase):
    __tablename__ = "group"
    group_name: Mapped[str] = mapped_column(sa.String())
    user: Mapped[User | None] = relationship(back_populates="group")

# ------------------------------------------------------------
# User DTO and read route
# ------------------------------------------------------------

class UserRead(SQLAlchemyDTO[User]):
  config: ClassVar[SQLAlchemyDTOConfig] = SQLAlchemyDTOConfig()

@get(path="/user", return_dto=UserRead)
async def get_user(db_session: AsyncSession) -> User:
    user = await db_session.get(User, 1)
    return user

# ------------------------------------------------------------
# App
# ------------------------------------------------------------

async def on_startup(app: Litestar) -> None:
    async with alchemy_config.get_session() as db_session:
        db_session.add(User(name="User 1"))
        await db_session.commit()


app = Litestar([get_user], plugins=[alchemy], on_startup=[on_startup], debug=True)
➜  localhost:8000/user
{"name":"User 1","group_id":null,"group":null,"id":1}%

URL to code causing the issue

No response

MCVE

from __future__ import annotations

from typing import TYPE_CHECKING, Any, ClassVar

if TYPE_CHECKING:
    from litestar.types import Dependencies

import sqlalchemy as sa
from advanced_alchemy.extensions.litestar import (
    AsyncSessionConfig,
    SQLAlchemyAsyncConfig,
    SQLAlchemyDTO,
    SQLAlchemyDTOConfig,
    SQLAlchemyPlugin,
    base,
)
from advanced_alchemy.extensions.litestar.providers import create_service_provider
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from litestar import Controller, Request
from litestar.app import Litestar
from litestar.connection import ASGIConnection
from litestar.di import Provide
from litestar.handlers import get
from litestar.response import Response
from litestar.security.jwt import JWTCookieAuth, Token
from sqlalchemy.orm import joinedload, mapped_column, relationship
from sqlalchemy.orm.base import Mapped

session_config = AsyncSessionConfig(expire_on_commit=False)
alchemy_config = SQLAlchemyAsyncConfig(
    connection_string="sqlite+aiosqlite:///:memory:",
    before_send_handler="autocommit",
    session_config=session_config,
    create_all=True,
)
alchemy = SQLAlchemyPlugin(config=alchemy_config)

# ------------------------------------------------------------
# User and Group Model and Service
# ------------------------------------------------------------

class User(base.BigIntBase):
    __tablename__ = "user"
    name: Mapped[str] = mapped_column(sa.String())
    group: Mapped["GroupMembership | None"] = relationship(back_populates="user")

class GroupMembership(base.BigIntBase):
    __tablename__ = "group"
    group_name: Mapped[str] = mapped_column(sa.String())
    user_id: Mapped[int] = mapped_column(sa.ForeignKey("user.id"))
    user: Mapped[User | None] = relationship(back_populates="group")

class UserService(SQLAlchemyAsyncRepositoryService[User]):
    class UserRepository(SQLAlchemyAsyncRepository[User]):
        model_type: type[User] = User

    repository_type: type[UserRepository] = UserRepository

# ------------------------------------------------------------
# Dependencies
# ------------------------------------------------------------

provide_user_service = create_service_provider(
    UserService,
    load=[joinedload(User.group)]
)

async def current_user_dependency(request: Request[User, Any, Any]) -> User:
    return request.user

# ------------------------------------------------------------
# DTO and User Controller
# ------------------------------------------------------------

class UserRead(SQLAlchemyDTO[User]):
  config: ClassVar[SQLAlchemyDTOConfig] = SQLAlchemyDTOConfig()

class UserController(Controller):
    dependencies: Dependencies | None = {
        "user_service": Provide(provide_user_service),
        "current_user": Provide(current_user_dependency),
    }
    path: str = "/user"

    @get(path="/me", return_dto=UserRead)
    async def get_user(self, current_user: User) -> User:
        return current_user

class LoginController(Controller):
    dependencies: Dependencies | None = {"user_service": Provide(provide_user_service)}
    path = "/login"

    @get(path="/", exclude_from_auth=True)
    async def login(self, user_service: UserService) -> Response[dict[str, str]]:
        user = await user_service.get_one(id=1)
        return auth.login(identifier=str(user.id), response_body={"message": "logged in"})

# ------------------------------------------------------------
# Guards
# -----------------------------------------------------------
async def get_current_user_from_token(token: Token, connection: ASGIConnection) -> User | None:
    service_provider = provide_user_service(
        alchemy_config.provide_session(connection.app.state, connection.scope)
    )
    async for service in service_provider:
        user = await service.get_one_or_none(id=int(token.sub))
        return user if user else None
    return None

auth = JWTCookieAuth[User](
    retrieve_user_handler=get_current_user_from_token,
    token_secret="secret",
    exclude=["/login"],
)

# ------------------------------------------------------------
# App
# -----------------------------------------------------------

async def on_startup(app: Litestar) -> None:
    async with alchemy_config.get_session() as db_session:
        db_session.add(User(name="User 1"))
        await db_session.commit()


app = Litestar(
    [UserController, LoginController],
    plugins=[alchemy],
    on_startup=[on_startup],
    debug=True,
    on_app_init=[auth.on_app_init],
)

Steps to reproduce

  1. Save the MCVE as app.py
  2. Run the following test script:
from litestar.testing import TestClient

from app import app

with TestClient(app) as client:
    login_response = client.get("/login")
    print(f"Login: {login_response.status_code}")
    
    me_response = client.get("/user/me")
    print(f"/user/me body: {me_response.text}")
  1. You will see the server raise a TypeError: Missing required argument 'group' error corresponding to the null group field for the User (since no group has been created in the database and assigned to the User).
  2. Disable the experimental_codegen_backend in the UserRead DTO (experimental_codegen_backend=False)
  3. Re-run the test script
  4. You will see the server correctly respond with the User model (with a null group field):
➜  litestar-dto-mre uv run test_mre.py
Login: 201
INFO - 2025-11-28 22:21:47,370 - httpx - _client - HTTP Request: GET http://testserver.local/login "HTTP/1.1 201 Created"
/user/me body: {"name":"User 1","group":null,"id":1}
INFO - 2025-11-28 22:21:47,372 - httpx - _client - HTTP Request: GET http://testserver.local/user/me "HTTP/1.1 200 OK"

Screenshots

No response

Logs

➜  uv run test_mre.py
Login: 201
INFO - 2025-11-28 21:54:17,504 - httpx - _client - HTTP Request: GET http://testserver.local/login "HTTP/1.1 201 Created"
ERROR - 2025-11-28 21:54:17,507 - litestar - config - Uncaught exception (connection_type=http, path='/user/me'):
Traceback (most recent call last):
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/middleware/_internal/exceptions/middleware.py", line 158, in __call__
    await self.app(scope, receive, capture_response_started)
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/routes/http.py", line 81, in handle
    response = await self._get_response_for_request(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        scope=scope, request=request, route_handler=route_handler, parameter_model=parameter_model
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/routes/http.py", line 133, in _get_response_for_request
    return await self._call_handler_function(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        scope=scope, request=request, parameter_model=parameter_model, route_handler=route_handler
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/routes/http.py", line 184, in _call_handler_function
    return await route_handler.to_response(app=scope["litestar_app"], data=response_data, request=request)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/handlers/http_handlers/base.py", line 581, in to_response
    data = return_dto_type(request).data_to_encodable_type(data)
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/dto/base_dto.py", line 124, in data_to_encodable_type
    return backend.encode_data(data)
           ~~~~~~~~~~~~~~~~~~~^^^^^^
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/dto/_codegen_backend.py", line 153, in encode_data
    return cast("LitestarEncodableType", self._encode_data(data))
                                         ~~~~~~~~~~~~~~~~~^^^^^^
  File "dto_transfer_function_fb84194f689f", line 83, in func
    tmp_return_type_0 = destination_type_0(**unstructured_data_0)
TypeError: Missing required argument 'group'
/user/me: 500
/user/me body: Traceback (most recent call last):
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/middleware/_internal/exceptions/middleware.py", line 158, in __call__
    await self.app(scope, receive, capture_response_started)
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/routes/http.py", line 81, in handle
    response = await self._get_response_for_request(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        scope=scope, request=request, route_handler=route_handler, parameter_model=parameter_model
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/routes/http.py", line 133, in _get_response_for_request
    return await self._call_handler_function(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        scope=scope, request=request, parameter_model=parameter_model, route_handler=route_handler
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/routes/http.py", line 184, in _call_handler_function
    return await route_handler.to_response(app=scope["litestar_app"], data=response_data, request=request)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/handlers/http_handlers/base.py", line 581, in to_response
    data = return_dto_type(request).data_to_encodable_type(data)
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/dto/base_dto.py", line 124, in data_to_encodable_type
    return backend.encode_data(data)
           ~~~~~~~~~~~~~~~~~~~^^^^^^
  File "/Users/colinsmall/GitHub/litestar-dto-mre/.venv/lib/python3.13/site-packages/litestar/dto/_codegen_backend.py", line 153, in encode_data
    return cast("LitestarEncodableType", self._encode_data(data))
                                         ~~~~~~~~~~~~~~~~~^^^^^^
  File "dto_transfer_function_fb84194f689f", line 83, in func
    tmp_return_type_0 = destination_type_0(**unstructured_data_0)
TypeError: Missing required argument 'group'
INFO - 2025-11-28 21:54:17,509 - httpx - _client - HTTP Request: GET http://testserver.local/user/me "HTTP/1.1 500 Internal Server Error"

Litestar Version

Litestar 2.18.0
Advanced Alchemy 1.8.0

Platform

  • Linux
  • Mac
  • Windows
  • Other (Please specify in the description above)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Bug 🐛This is something that is not working as expected

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions