Repository navigation
JSON Fields for Nested Pydantic Models? #63
Description
Activity
- addedquestionFurther information is requestedFurther information is requested
on Aug 31, 2021 I also wondered how to store JSON objects without converting to string. SQL Alchemy supports storing these directly
Reacted by Adem Usta, HenningLindhorst, Riyad Parvez, Emil 'Skeen' Madsen, Lauri Mesilaakso, Zaffer, Matthieu LAURENT, aleb_the_flash and Jane Jeon@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 :)
Reacted by Santiago Cuervo, 林煒清(Lin Wei-Ching), Saeed Esmaili, Nikolay Seliverstov, Alex Kosh, Filipe Marchesini, Riyad Parvez, Emil 'Skeen' Madsen, Masoud Masoumi Moghadam, eugene and 7 moreReacted by gulnara-ibragimova, Saeed Esmaili, Filipe Marchesini, Emil 'Skeen' Madsen, eugene, gsouveton, akc and Dude29I also got it working, on SQLite and Postgresql:
mygreatfield: Dict[Any, Any] = Field(index=False, sa_column=Column(JSON))
needsfrom sqlmodel import Field, SQLModel, Column, JSONas well asfrom typing import Dict, AnyReacted by Jed Palmater, Santiago Cuervo, 杜育轩, 林煒清(Lin Wei-Ching), Raul Escobar, viktorov-aa, proxseas, Louis, Filipe Marchesini, Riyad Parvez and 8 more@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)}]]Reacted by espdev, Andrei, Pavel Demin and minoicReacted by Zaffer, Tridagger, Jaakko Sirén, AlegntayeYilma, Isaac Whitfield and Xiang ZHUThank 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.
Reacted by Sobytes, Andrea Parisi, Tobias Gårdhus, espdev and Anton FominReacted by Sobytes, Andrei, Sebastian Weigand and Armando SalazarHey @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)
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.
It does type check when you create the
JObject (which it should) So if you tried to supply a string it would failJ(j="foo")This allows for the type checking of the object, the
Aclass 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.
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)
Reacted by aistellar and MRezReacted by Sobytes and Tang ZiyaReacted by Hichem Rekouane and teodoryantcheffReacted by Samuel J Palmer, Patrik Hlobil, Alex Fadeenko, hakanoktay, Matthew Aylward , Kinnaird McQuade, eugene, gsouveton, Yudhiesh Ravindranath, Juan Pablo Mallarino and 5 moreReacted by Hichem Rekouane and teodoryantcheffhi,
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?
Does this work?
@validator('adddresses') def val_b(cls, value): print(value) return [v.dict() for v in value]Reacted by hakanoktay, Hyeongwoo Kim and Matthieu LAURENTDoes 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.
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 = TrueHowever, when reading the model from the DB using a
select()I would want the JSON field to be read into a ComplexHeroField class using pydanticsparse_raworparse_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!
Reacted by Zaffer, Arnaud Durand, Joris Guerry, Matthieu LAURENT and Victor Dibia44 remaining items
^^ 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
@fny looks great ! Can you provide an example on usage and migrations (with alembic) ?
@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.
@fny would love to merge these updates into the active model project if you're up for submitting a PR
I am here to bump this. I would love to see this added!
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])))
Reacted by Daan Rademaker, Igor Potapkin, Josh Borrow, Mike Cantrell, Dude29 and minoicReacted by Jules Lasne@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.
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:
- That long path is not recognized in the migration file but I can work around that by adding import statements
- 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)?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 writeColumn(PydanticColumn(...))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.
Here is another variation for
postgresql+asyncpg+JSONBbased on @pporcher's solution and @Alex-S-H-P's solution.- Returns
strinbind_processor, as postgresql + asyncpg dialect expects str for encoding (source) - Uses
TypeAdapter.validate_pythoninresult_processorto work with Python object. postgresql + asyncpg dialect loads the result to Python object by default (doc, source). - Since
result_processorprocesses 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
alembichookrender_itemin 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 throughautogen_context.imports.addfrom 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.TypeDecoratorAPI to overrideprocess_bind_paramandprocess_result_value.- TypeDecorator.bind_processor: Runs process_bind_param to convert the value before calling impl.bind_processor.
- TypeDecorator.result_processor: Runs process_result_value after calling impl.result_processor to convert the value. For the asyncpg case, impl.result_processor is None so process_result_value is called directly, see result_processor below for details.
- JSONB.bind_processor: Takes a Python object and returns a JSON string.
- JSONB.result_processor: Takes a JSON string/bytes and returns a Python object. This is overriden in AsyncpgJSONB (as of sqlalchemy v2.0.49) because asyncpg provides dialects level deserialization.
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_processordirectly due to double serialization, i.e.(collections of) Pydantic model ---> Python object ---> strinstead 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)
- Returns
- addedfeatureNew feature or requestNew feature or requestand removedquestionFurther information is requestedFurther information is requested
on May 18, 2026 - locked and limited conversation to collaborators
on May 18, 2026
First Check
Commit to Help
Example Code
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