Skip to content

JSON Fields for Nested Pydantic Models? #63

Description

@scuervo91

First Check

  • I added a very descriptive title to this issue.
  • I used the GitHub search to find a similar issue and didn't find it.
  • I searched the SQLModel documentation, with the integrated search.
  • I already searched in Google "How to X in SQLModel" and didn't find any information.
  • I already read and followed all the tutorial in the docs and didn't find an answer.
  • I already checked if it is not related to SQLModel but to Pydantic.
  • I already checked if it is not related to SQLModel but to SQLAlchemy.

Commit to Help

  • I commit to help with one of those options 👆

Example Code

from tortoise.models import Model 
from tortoise.fields import UUIDField, DatetimeField,CharField, BooleanField, JSONField, ForeignKeyField, CharEnumField, IntField
from tortoise.contrib.pydantic import pydantic_model_creator

class Schedule(Model):
    id = UUIDField(pk=True)
    created_at = DatetimeField(auto_now_add=True)
    modified_at = DatetimeField(auto_now=True)
    case = JSONField()
    type = CharEnumField(SchemasEnum,description='Schedule Types')
    username = ForeignKeyField('models.Username')
    description = CharField(100)
    
schedule_pydantic = pydantic_model_creator(Schedule,name='Schedule')

Description

I have already implemented an API using FastAPI to store Pydantic Models. These models are themselves nested Pydantic models so the way they interact with a Postgres DataBase is throught JsonField. I've been using Tortoise ORM as the example shows.

Is there an equivalent model in SQLModel?

Operating System

Linux

Operating System Details

WSL 2 Ubuntu 20.04

SQLModel Version

0.0.4

Python Version

3.8

Additional Context

No response

Activity

  1. OXERY commented on Sep 3, 2021

    @OXERY

    I also wondered how to store JSON objects without converting to string. SQL Alchemy supports storing these directly

  2. TheJedinator commented on Sep 9, 2021

    @TheJedinator

    @OXERY && @scuervo91 - I was able to get something that works Using this:

    regions: dict = Field(sa_column=Column(JSON), default={'all': 'true'})

    That said: this is a postgresql JSONB column in my database. But it works.

    For a nested Object you could use a pydantic model as the Type and do it the same way. Hope this helps as I was having a difficult time figuring out a solution as well :)

  3. OXERY commented on Sep 10, 2021

    @OXERY

    I also got it working, on SQLite and Postgresql:
    mygreatfield: Dict[Any, Any] = Field(index=False, sa_column=Column(JSON))
    needs from sqlmodel import Field, SQLModel, Column, JSON as well as from typing import Dict, Any

  4. psarka commented on Dec 1, 2021

    @psarka

    @TheJedinator Could you help a bit more with the nested object? I tried to "use the pydantic model as the Type" but I can't get it to work :( Here is my snippet:

    from sqlalchemy import Column
    from sqlalchemy.dialects.postgresql import JSONB
    from sqlmodel import Field
    from sqlmodel import Session
    from sqlmodel import SQLModel
    
    from engine import get_sqlalchemy_engine
    
    
    class J(SQLModel):
        j: int
    
    
    class A(SQLModel, table=True):
        a: int = Field(primary_key=True)
        b: J = Field(sa_column=Column(JSONB))
    
    
    engine = get_sqlalchemy_engine()
    SQLModel.metadata.create_all(engine)
    
    with Session(engine) as session:
        a = A(a=1, b=J(j=1))
        session.add(a)
        session.commit()
        session.refresh(a)

    Throws an error

    sqlalchemy.exc.StatementError: (builtins.TypeError) Object of type J is not JSON serializable
    [SQL: INSERT INTO a (b, a) VALUES (%(b)s, %(a)s)]
    [parameters: [{'a': 1, 'b': J(j=1)}]]
    
  5. TheJedinator commented on Dec 1, 2021

    @TheJedinator
  6. psarka commented on Dec 1, 2021

    @psarka

    Thank you! Unfortunately I get the same error :(

    I found one workaround - registering a custom_serializer for the sqlalchemy engine, like so:

    def custom_serializer(d):
        return json.dumps(d, default=lambda v: v.json())
    
    def get_sqlalchemy_engine():
        return create_engine("postgresql+psycopg2://", creator=get_conn, json_serializer=custom_serializer)

    But if there is a cleaner way, I would gladly use that instead.

  7. TheJedinator commented on Dec 2, 2021

    @TheJedinator

    Hey @psarka

    I just actually tried what I told and sorry have mislead... I did get a working solution though 😄

    It was actually the opposite function that you need to use, here's the example you supplied with the amendments to make it work:

    with Session(engine) as session:
        j = J(j=1)
        j_dumped = J.json(j)
        a = A(a=1, b=j_dumped)
        session.add(a)
        session.commit()
        session.refresh(a)
  8. psarka commented on Dec 2, 2021

    @psarka

    Hmm, this doesn't (or at least shouldn't) typecheck :)

    But I see what you did there, essentially it's the same as registring a custom serializer, but manually.

  9. TheJedinator commented on Dec 2, 2021

    @TheJedinator

    It does type check when you create the J Object (which it should) So if you tried to supply a string it would fail J(j="foo")

    This allows for the type checking of the object, the A class requires a serialized version of J in order for it to be entered in to the database.

    It is essentially the same as registering a custom serializer but allows you to be explicit about using it.

  10. HenningScheufler commented on Jan 9, 2022

    @HenningScheufler

    A hacky method with type checking that work with sqlite is

    from sqlalchemy import Column
    from typing import List
    # from sqlalchemy.dialects.postgresql import JSONB
    from sqlmodel import Field
    from sqlmodel import Session
    from pydantic import validator
    from sqlmodel import SQLModel, JSON,create_engine
    
    # from engine import get_sqlalchemy_engine
    sqlite_file_name = "test.db"
    sqlite_url = f"sqlite:///{sqlite_file_name}"
    
    engine = create_engine(sqlite_url)
    
    
    class J2(SQLModel):
        test: List[int]
    
    class J(SQLModel):
        j: int
        nested: J2
    
    
    class A(SQLModel, table=True):
        a: int = Field(primary_key=True)
        b: J = Field(sa_column=Column(JSON))
    
        @validator('b')
        def val_b(cls, val):
            return val.dict()
    
    SQLModel.metadata.create_all(engine)
    
    with Session(engine) as session:
        a = A(a=1, b=J(j=1,nested=J2(test=[100,100,100])))
        session.add(a)
        session.commit()
        session.refresh(a)
  11. hakanoktay commented on Feb 10, 2022

    @hakanoktay

    hi,
    I created a "JSON Field" based on what is written here. I am using SQLite.

    from sqlmodel import SQLModel,Relationship,Field,JSON
    from typing import Optional,List, Dict
    from sqlalchemy import Column
    from pydantic import validator
    
    
    #
    class J2(SQLModel):
        id: int
        title:str
    
    #
    class Companies(SQLModel, table=True):
        id:Optional[int]=Field(default=None,primary_key=True)
        name:str
        adddresses: List['J2'] = Field(sa_column=Column(JSON))
    
    
        @validator('adddresses')
        def val_b(cls, val):
            print(val)
            return val.dict()

    Given error.

    TypeError: Type is not JSON serializable: J2

    when i print it, it returns

    [J2(id=1, title='address1'), J2(id=2, title='address2')]

    how can i handle that? Why is this J2 added, how can I get rid of it, i can't turn it to .dict(), i cannot serialise it... can you give an idea?

  12. HenningScheufler commented on Feb 10, 2022

    @HenningScheufler

    Does this work?

        @validator('adddresses')
        def val_b(cls, value):
            print(value)
            return [v.dict() for v in value]
    
  13. hakanoktay commented on Feb 10, 2022

    @hakanoktay

    Does this work?

        @validator('adddresses')
        def val_b(cls, value):
            print(value)
            return [v.dict() for v in value]
    

    @HenningScheufler thank you for your help, it worked perfect.

  14. MaximilianFranz commented on Mar 16, 2022

    @MaximilianFranz

    Hey all,

    thanks for the great advice here. Creating a the object using the classes and writing them to the DB works as expected and writes the data as a dict into a JSON field.

    See this example:

    class ComplexHeroField(SQLModel, table=False):
        some: str
        other: float
        more: Optional[List[str]]
    
    class Hero(SQLModel, table=True):
        id: Optional[int] = Field(default=None, primary_key=True)
        complex_field: ComplexHeroField = Field(sa_column=Column(JSON))
        name: str
        secret_name: str
        age: Optional[int] = None
    
        @validator('complex_field')
        def val_complex(cls, val: ComplexHeroField):
            # Used in order to store pydantic models as dicts
            return val.dict()
    
        class Config:
            arbitrary_types_allowed = True
    

    However, when reading the model from the DB using a select() I would want the JSON field to be read into a ComplexHeroField class using pydantics parse_raw or parse_obj. Because they way it's currently done (with the validator) this happens:

            statement = select(Hero)
            results = session.exec(statement)
            for hero in results:
                print(hero.complex_field.some)
    
           # AttributeError: 'dict' object has no attribute 'some'
    
    

    Any hint how that could be achieved? Maybe via the custom-serialiser mentioned by @psarka ?

    Thanks already!

  15. 44 remaining items

  16. DaanRademaker commented on Mar 13, 2025

    @DaanRademaker

    ^^ Very nice! Tested the above seems to work great so far!

    Also I had to add 2 lines of code to make sure it works with None default values being returned if the type is an array or list (this is technically possible).

      if not origin:  # not a container type (e.g. int, untyped list, None, datetime)
            return raw_value
      if raw_value is None:
          return None
  17. Seluj78 commented on Mar 13, 2025

    @Seluj78

    @fny looks great ! Can you provide an example on usage and migrations (with alembic) ?

  18. fny commented on Mar 13, 2025

    @fny

    @DaanRademaker I just realized that error myself. I updated my version to make it more robust and also avoid an issue where setattr(...) was triggering validations. @Seluj78: I added an example. Migrations will work without any additional changes.

    Other note: you need to call record.init_on_load() after you commit the record to the database, otherwise sqlalchemy will overwrite the field with a dict-like object.

    I'm sure there's something I could add to the mixin, but I haven't had time to investigate.

  19. iloveitaly commented on Mar 15, 2025

    @iloveitaly

    @fny would love to merge these updates into the active model project if you're up for submitting a PR

  20. amanmibra commented on Mar 16, 2025

    @amanmibra

    I am here to bump this. I would love to see this added!

  21. pporcher commented on Mar 16, 2025

    @pporcher

    Here is how I do it using pydantic's TypeAdapter.

    from sqlalchemy import TypeDecorator
    from sqlmodel import JSON
    from pydantic import TypeAdapter
    
    class PydanticJson(TypeDecorator):
        impl = JSON()
        cache_ok = True
    
        def __init__(self, pt):
            super().__init__()
            self.pt = TypeAdapter(pt)
            self.coerce_compared_value = self.impl.coerce_compared_value
    
        def bind_processor(self, dialect):
            return lambda value: self.pt.dump_json(value) if value is not None else None
    
        def result_processor(self, dialect, coltype):
            return lambda value: self.pt.validate_json(value) if value is not None else None

    And how to use it.

    from sqlalchemy import Column
    from pydantic import BaseModel
    from sqlmodel import SQLModel, Field
    
    class Nested(BaseModel):
        value: str
    
    class Parent(SQLModel, table=True):
        id: int = Field(primary_key=True, default=None)
        nested: Nested | None = Field(sa_column=Column(PydanticJson(Nested)))
        nested_list: list[Nested] = Field(sa_column=Column(PydanticJson(list[Nested])))
  22. fny commented on Apr 15, 2025

    @fny

    @fny would love to merge these updates into the active model project if you're up for submitting a PR

    Hey @iloveitaly! Sorry for the late response. I just saw this. I'll try to get around to it this week.

  23. Dude29 commented on Jun 2, 2025

    @Dude29

    I tried using @pporcher solution and it works great to create the tables and get running.
    But another problem I faced further ahead was when auto generating migrations using Alembic.

    It generated the migration file with code like this:
    sa.Column('accessories', a.very.long.path.to.the.class.PydanticJson(), nullable=False),

    Which has two issues:

    1. That long path is not recognized in the migration file but I can work around that by adding import statements
    2. Alembic doesn't know how to use PydanticJson() and doesnt pass the pydantic model in its constructor

    I guess the solution is to just tell Alembic to create the column as type JSON and go from there but im not sure how to do that.
    Does anyone know how to tell Alembic to map a type to another one(if this is even possible)?

  24. Alex-S-H-P commented on Aug 26, 2025

    @Alex-S-H-P

    For the specific use-case where one wants to store in a JSON column a list of Pydantic Model instances, I built on @pporcher's answer

    # sql.py
    from typing import Any, Generic, Self, TypeVar
    from sqlalchemy import Column, Dialect, TypeDecorator
    from sqlalchemy.sql.operators import OperatorType
    from sqlalchemy.sql.type_api import _BindProcessorType, _ResultProcessorType
    from sqlmodel import JSON
    from pydantic import BaseModel, TypeAdapter
    
    
    T = TypeVar("T", bound=BaseModel)
    
    
    class PydanticColumn(TypeDecorator, Generic[T]):
        impl = JSON
        cache_ok = True
    
        def __init__(self, pt: type[T]):
            super().__init__()
            self.adapter: TypeAdapter[T] = TypeAdapter(pt)
    
        def coerce_compared_value(self, op: OperatorType | None, value: Any) -> Any:
            return self.impl.coerce_compared_value(self, op, value)  # type: ignore
    
        def bind_processor(self, dialect: Dialect) -> _BindProcessorType | None:
            def processor(value: T | None) -> bytes | None:
                if value is None:
                    return None
                return self.adapter.dump_json(value)
            return processor
    
        def result_processor(self, dialect: Dialect, coltype: Any) -> _ResultProcessorType | None:
            def processor(value: bytes | str | None):
                if value is None:
                    return None
                return self.adapter.validate_json(value)
            return processor
    
        @classmethod
        def col(cls, model: type[T]) -> Column[Self]:
            return Column(cls(model))
    from pydantic import RootModel, BaseModel
    from sqlmodel import Field, SQLModel
    from .sql import PydanticColumn
    
    class Item(BaseModel):
        item: str
    
    class List(RootModel[list[Item]], Sequence[Item]):
        root: list[Item]
        def __len__(self) -> int:
            return len(self.root)
    
        @overload
        def __getitem__(self, item: int) -> Item:
            ...
    
        @overload
        def __getitem__(self, item: slice[int | None, int | None, int | None]) -> Sequence[Item]:
            ...
    
        def __getitem__(self, item: int | slice[int | None, int | None, int | None]) -> Item | Sequence[Item]:
            return self.root[item]
    
    class MyTable(SQLModel, table=True):
         ...  # other columns here
         custom_list: List = Field(default_factory=list, sa_column=PydanticColumn.col(List))

    I added the PydanticColumn.col(...) classmethod which is a quicker way to write Column(PydanticColumn(...))

  25. iloveitaly commented on Apr 9, 2026

    @iloveitaly

    I recently added JSONB field mutation tracking to activemodel. This works both for JSOB fields that render as Pydantic objects and plain old py objects.

  26. CHC383 commented on May 11, 2026

    @CHC383

    Here is another variation for postgresql + asyncpg + JSONB based on @pporcher's solution and @Alex-S-H-P's solution.

    • Returns str in bind_processor, as postgresql + asyncpg dialect expects str for encoding (source)
    • Uses TypeAdapter.validate_python in result_processor to work with Python object. postgresql + asyncpg dialect loads the result to Python object by default (doc, source).
    • Since result_processor processes Python object, it will work directly with Pydantic model or list of Pydantic model.
    from typing import TYPE_CHECKING, Any, cast, override
    
    from pydantic import TypeAdapter
    from sqlalchemy import Dialect, TypeDecorator
    from sqlalchemy.dialects.postgresql import JSONB
    
    if TYPE_CHECKING:
        from sqlalchemy.sql.type_api import _BindProcessorType, _ResultProcessorType
    
    
    class PydanticJSONB[T](TypeDecorator):
        impl = JSONB()
        cache_ok = True
    
        def __init__(self, pydantic_type: type[T]) -> None:
            super().__init__()
            self.adapter = TypeAdapter(pydantic_type)
            self.coerce_compared_value = cast(
                "JSONB", PydanticJSONB.impl
            ).coerce_compared_value
    
        @override
        def bind_processor(self, dialect: Dialect) -> _BindProcessorType:
            def processor(value: T | None) -> str | None:
                if value is None:
                    return None
                return self.adapter.dump_json(value).decode("utf-8")
    
            return processor
    
        @override
        def result_processor(self, dialect: Dialect, coltype: Any) -> _ResultProcessorType:
            def processor(value: Any) -> T:
                if value is None:
                    return None
                return self.adapter.validate_python(value)
    
            return processor

    Inspired by #1324 (comment), the alembic hook render_item in env.py can be used to generate the migration scripts properly. @Dude29 you could either replace the new class with the original sqlalchemy type in the migration script, or add import for the new class through autogen_context.imports.add

    from typing import TYPE_CHECKING, Any, Literal
    
    if TYPE_CHECKING:
        from alembic.autogenerate.api import AutogenContext
    
    def render_item(
        type_: str,
        obj: Any,  # noqa: ANN401
        autogen_context: AutogenContext,
    ) -> str | Literal[False]:
        if type_ == "type" and isinstance(obj, PydanticJSONB):
            autogen_context.imports.add("import sqlalchemy as sa")
            autogen_context.imports.add("from sqlalchemy.dialects import postgresql")
            return "postgresql.JSONB(astext_type=sa.Text())"
        return False

    Inspired by #1324, here is another implementation which is less hacky and follows the sqlalchemy.TypeDecorator API to override process_bind_param and process_result_value.

    So effectively the process is

    • Serialization: (collections of) Pydantic model -> process_bind_param to convert to Python object -> JSONB bind processor to convert to str -> asyncpg dialect to convert to bytes
    • Deserializtion: asyncpg dialect convert bytes to Python object -> process_result_value to convert to (collections of) Pydantic model

    As a result, this implementation is less performant than overriding bind_processor directly due to double serialization, i.e. (collections of) Pydantic model ---> Python object ---> str instead of (collections of) Pydantic model ---> str. Deserialization is not affected.

    from typing import Any, cast, override
    
    from pydantic import TypeAdapter
    from sqlalchemy import Dialect, TypeDecorator
    from sqlalchemy.dialects.postgresql import JSONB
    
    
    class PydanticJSONB[T](TypeDecorator):
        impl = JSONB()
        cache_ok = True
    
        def __init__(self, pydantic_type: type[T]) -> None:
            super().__init__()
            self.adapter = TypeAdapter(pydantic_type)
            self.coerce_compared_value = cast(
                "JSONB", PydanticJSONB.impl
            ).coerce_compared_value
    
        @override
        def process_bind_param(self, value: T | None, dialect: Dialect) -> Any:
            if value is None:
                return None
            return self.adapter.dump_python(value)
    
        @override
        def process_result_value(self, value: Any, dialect: Dialect) -> T | None:
            if value is None:
                return None
            return self.adapter.validate_python(value)
  27. added
    featureNew feature or request
    and removed
    questionFurther information is requested
    on May 18, 2026
  28. locked and limited conversation to collaborators on May 18, 2026
  29. converted this issue into a discussion #1925 on May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions