Validate SQLModel on all Non-Database Sourced Data - #1823

Open
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction
Open

Validate SQLModel on all Non-Database Sourced Data#1823
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction

Conversation

@ClayGendron

Copy link
Copy Markdown

As noted by other issues and pull requests, when setting table=True in a SQLModel, Pydantic validation does not run, and this breaks the contract that "a SQLModel model is also a Pydantic model". This PR builds on prior ones and also hopes to address concerns with changing the intentional validation bypass for table models.

First, for those new to this issue, here is an example of how SQLModels behave differently when they are a table:

frompydanticimportBaseModel, ValidationErrorfromsqlmodelimportSQLModel, FieldclassHeroBase(BaseModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLBase(SQLModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLTable(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strage: inttry:
HeroBase(name="Deadpond", age="not an int")
print("HeroBase: created with invalid data!")
exceptValidationError:
print("HeroBase: ValidationError raised")
try:
HeroSQLBase(name="Deadpond", age="not an int")
print("HeroSQLBase: created with invalid data!")
exceptValidationError:
print("HeroSQLBase: ValidationError raised")
try:
HeroSQLTable(name="Deadpond", age="not an int")
print("HeroSQLTable: created with invalid data!")
exceptValidationError:
print("HeroSQLTable: ValidationError raised")
HeroBase: ValidationError raised
HeroSQLBase: ValidationError raised
HeroSQLTable: created with invalid data!

The same is true for @field_validator and @model_validator. Both will be silently ignored when table=True.

Use Case

I am building a library that includes a base SQLModel class (without table=True) that has validators to normalize and populate data. Downstream developers using my library would then create their own table=True class that inherits from the base:

# ---- library code ----importhashlibimportposixpathimportuuidfrompydanticimportfield_validator, model_validatorfromsqlmodelimportSQLModel, FieldclassDocumentBase(SQLModel):
id: str=Field(
default_factory=lambda: str(uuid.uuid4()), max_length=256, primary_key=True
)
path: strcontent: strcontent_hash: str=""@field_validator("path")@classmethoddefnormalize_path(cls, v: str) ->str:
ifnotv.startswith("/"):
v="/"+vreturnposixpath.normpath(v)
@model_validator(mode="after")defcompute_content_hash(self) ->"DocumentBase":
self.content_hash=hashlib.sha256(self.content.encode()).hexdigest()
returnself# ---- downstream developer code ----classDocument(DocumentBase, table=True):
project_field: str|None=None

The base class works correctly on its own, but it can't hold the downstream project_field:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
DocumentBase(
path='/path', # normalizedcontent='hello',
content_hash='2cf24db...', # computed# project_field missing!
)

But when a downstream developer inherits with table=True, both validators are silently skipped:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
Document(
path='not_normalized/../path', # not normalized!content='hello',
content_hash='', # not computed!project_field='important info!',
)

My library must rely on validation from the custom defined inherited class, but it will not work out of the box. My issue could be resolved with the solution described in the multiple models doc, but that approach would mean I would be asking developers to create twice as many classes, one for validation and one for table mapping. Using SQLModel was chosen for this project as it promised to provide a unified model between Pydantic and SQLAlchemy, which in my case, means any initialized model derived from DocumentBase is valid, both in python and in the database.

The Change

The change is in sqlmodel_init() in _compat.py. Previously, table models called sqlmodel_table_construct() which skips validation entirely. Now, all models go through validate_python(), and table models do a post-validation step to re-trigger SQLAlchemy instrumentation via setattr:

defsqlmodel_init(*, self: "SQLModel", data: dict[str, Any]) ->None:
old_dict=self.__dict__.copy()
self.__pydantic_validator__.validate_python(
data,
self_instance=self,
)
ifnotis_table_model_class(self.__class__):
object.__setattr__(
self,
"__dict__",
{**old_dict, **self.__dict__},
)
else:
fields_set=self.__pydantic_fields_set__.copy()
forkey, valuein {**old_dict, **self.__dict__}.items():
setattr(self, key, value)
object.__setattr__(self, "__pydantic_fields_set__", fields_set)
forkeyinself.__sqlmodel_relationships__:
value=data.get(key, Undefined)
ifvalueisnotUndefined:
setattr(self, key, value)

This mirrors the existing pattern used by sqlmodel_validate() (the model_validate() path) which already validates table models successfully.

Addressing Prior Concerns

"SQLAlchemy needs to assign values after instantiation" (#52)

The concern raised in #52 was that relationships need to be assignable after construction, so validation can't run on __init__.

Relationships are not part of model_fields — they live in __sqlmodel_relationships__ and are handled separately, outside of Pydantic validation. validate_python() never sees or validates relationship attributes. Both sides of a bidirectional relationship can be created independently, exactly as before:

fromsqlmodelimportSQLModel, Field, Relationship, Session, create_engineclassTeam(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strheroes: list["Hero"] =Relationship(back_populates="team")
classHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strteam_id: int|None=Field(default=None, foreign_key="team.id")
team: Team|None=Relationship(back_populates="heroes")
# Create each side independently — no relationship passedteam=Team(name="Preventers")
hero=Hero(name="Deadpond")
# Assign relationship after constructionhero.team=teamengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
withSession(engine) assession:
session.add(hero)
session.commit()
session.refresh(hero)
print(f"{hero.name}'s team: {hero.team.name}")
Deadpond's team: Preventers

Performance on ORM Reads

Validation does not run when loading from the database. SQLAlchemy does not call __init__ when hydrating instances from query results (SQLAlchemy docs: Constructors and Object Initialization). This is unchanged, as it is safe to assume that data loaded from the database is valid.

To verify, here is a test that writes invalid data directly to the database and confirms it loads without triggering validation:

frompydanticimportfield_validatorfromsqlmodelimportSQLModel, Field, Session, create_engine, selectclassHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: str@field_validator("name")@classmethoddefname_must_be_short(cls, v: str) ->str:
iflen(v) >5:
raiseValueError("too long")
returnvengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
# Insert valid data through the modelwithSession(engine) assession:
session.add(Hero(name="short"))
session.commit()
# Write invalid data directly to the database, bypassing the modelwithengine.connect() asconn:
conn.execute(
Hero.__table__.update()
.where(Hero.__table__.c.id==1)
.values(name="this is way too long")
)
conn.commit()
# Load from database — no validation runs, invalid data loads finewithSession(engine) assession:
loaded=session.exec(select(Hero)).first()
print(f"Loaded from DB: {loaded.name!r}")
Loaded from DB: 'this is way too long'

Breaking Change

This could represent a behavior change for code that previously constructed table=True models with invalid data.

Related Issues and PRs

  • #52 — SQLModel doesn't raise ValidationError
  • #453 — Why does a SQLModel class with table=True not validate data?
  • #134 — Pydantic Validators does not raise ValueError if conditions are not met
  • #1041 — Ensure that type checks are executed when setting table=True
  • #227 — Class Initialisation Validation Kwarg

Thank you for reviewing!

Enable Pydantic validation on table=True model __init__, matching the
existing behavior of model_validate(). Validation does not run on ORM
loads from the database — SQLAlchemy does not call __init__ when
hydrating from query results.

@mahdirajaeemahdirajaee left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a significant behavioral change — table models now run Pydantic validation on __init__ instead of bypassing it via sqlmodel_table_construct. The key insight is that validate_python(data, self_instance=self) is called unconditionally for both table and non-table models now, which means field validators, model validators, and type coercion all fire during construction. The test suite additions are excellent and cover the critical scenarios: field_validator, model_validator (both before/after modes), BeforeValidator via Annotated, and crucially test_validation_does_not_run_on_orm_load which verifies that loading from the database still bypasses validation (important for not breaking existing data that might not pass newer validators).

The removal of sqlmodel_table_construct is the right call since it was essentially a copy of Pydantic's model_construct() that skipped validation — which was the original bug. One thing to watch: this is a breaking change for users who relied on being able to instantiate table models with missing required fields (the test_not_allow_instantiation_without_arguments test makes this explicit). The old test test_allow_instantiation_without_arguments previously passed with Item() where name: str had no default — that will now raise ValidationError. Projects doing item = Item(); item.name = "..." will need to migrate to Item(name="...").

The sqlmodel_init branching for table vs non-table models post-validation is sensible: non-table models merge dicts directly, while table models use setattr to trigger SQLAlchemy instrumentation and manually restore __pydantic_fields_set__. Worth confirming that the fields_set save/restore around the setattr loop doesn't cause issues if a setattr triggers a SQLAlchemy event that modifies __pydantic_fields_set__ internally, though that seems unlikely in practice.

@svlandeg

This comment was marked as off-topic.

@parthmishra

This comment was marked as off-topic.

@Graeme22

Graeme22 commented May 5, 2026

Copy link
Copy Markdown

This is great work and desperately needed! I hope @tiangolo will consider these changes. Might have to bump the middle version number, though the breakage this will cause will mostly be "good" in that it will catch incorrect code that was silently passing before.

In my projects I have a lot of code like:

classHeroValidate(SQLModel):
age: intclassHero(HeroValidate, table=True):
id: int=Field(...)
hero=HeroValidate(42)
hero_db=Hero(**hero.model_dump())
session.add(hero_db)

This is of course unwieldy, and it's quite clear many users have been bitten by this behavior at one point or another, so it should be a high priority to fix.

@clstaudt

Copy link
Copy Markdown

this breaks the contract that "a SQLModel model is also a Pydantic model".

Exactly. I was very surprised to realise that this is not actually the case. I hope this can be corrected soon.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@ClayGendron@svlandeg@parthmishra@Graeme22@clstaudt@mahdirajaee@tiangolo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Validate SQLModel on all Non-Database Sourced Data - #1823

Open
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction
Open

Validate SQLModel on all Non-Database Sourced Data#1823
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction

Conversation

@ClayGendron

Copy link
Copy Markdown

As noted by other issues and pull requests, when setting table=True in a SQLModel, Pydantic validation does not run, and this breaks the contract that "a SQLModel model is also a Pydantic model". This PR builds on prior ones and also hopes to address concerns with changing the intentional validation bypass for table models.

First, for those new to this issue, here is an example of how SQLModels behave differently when they are a table:

frompydanticimportBaseModel, ValidationErrorfromsqlmodelimportSQLModel, FieldclassHeroBase(BaseModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLBase(SQLModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLTable(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strage: inttry:
HeroBase(name="Deadpond", age="not an int")
print("HeroBase: created with invalid data!")
exceptValidationError:
print("HeroBase: ValidationError raised")
try:
HeroSQLBase(name="Deadpond", age="not an int")
print("HeroSQLBase: created with invalid data!")
exceptValidationError:
print("HeroSQLBase: ValidationError raised")
try:
HeroSQLTable(name="Deadpond", age="not an int")
print("HeroSQLTable: created with invalid data!")
exceptValidationError:
print("HeroSQLTable: ValidationError raised")
HeroBase: ValidationError raised
HeroSQLBase: ValidationError raised
HeroSQLTable: created with invalid data!

The same is true for @field_validator and @model_validator. Both will be silently ignored when table=True.

Use Case

I am building a library that includes a base SQLModel class (without table=True) that has validators to normalize and populate data. Downstream developers using my library would then create their own table=True class that inherits from the base:

# ---- library code ----importhashlibimportposixpathimportuuidfrompydanticimportfield_validator, model_validatorfromsqlmodelimportSQLModel, FieldclassDocumentBase(SQLModel):
id: str=Field(
default_factory=lambda: str(uuid.uuid4()), max_length=256, primary_key=True
)
path: strcontent: strcontent_hash: str=""@field_validator("path")@classmethoddefnormalize_path(cls, v: str) ->str:
ifnotv.startswith("/"):
v="/"+vreturnposixpath.normpath(v)
@model_validator(mode="after")defcompute_content_hash(self) ->"DocumentBase":
self.content_hash=hashlib.sha256(self.content.encode()).hexdigest()
returnself# ---- downstream developer code ----classDocument(DocumentBase, table=True):
project_field: str|None=None

The base class works correctly on its own, but it can't hold the downstream project_field:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
DocumentBase(
path='/path', # normalizedcontent='hello',
content_hash='2cf24db...', # computed# project_field missing!
)

But when a downstream developer inherits with table=True, both validators are silently skipped:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
Document(
path='not_normalized/../path', # not normalized!content='hello',
content_hash='', # not computed!project_field='important info!',
)

My library must rely on validation from the custom defined inherited class, but it will not work out of the box. My issue could be resolved with the solution described in the multiple models doc, but that approach would mean I would be asking developers to create twice as many classes, one for validation and one for table mapping. Using SQLModel was chosen for this project as it promised to provide a unified model between Pydantic and SQLAlchemy, which in my case, means any initialized model derived from DocumentBase is valid, both in python and in the database.

The Change

The change is in sqlmodel_init() in _compat.py. Previously, table models called sqlmodel_table_construct() which skips validation entirely. Now, all models go through validate_python(), and table models do a post-validation step to re-trigger SQLAlchemy instrumentation via setattr:

defsqlmodel_init(*, self: "SQLModel", data: dict[str, Any]) ->None:
old_dict=self.__dict__.copy()
self.__pydantic_validator__.validate_python(
data,
self_instance=self,
)
ifnotis_table_model_class(self.__class__):
object.__setattr__(
self,
"__dict__",
{**old_dict, **self.__dict__},
)
else:
fields_set=self.__pydantic_fields_set__.copy()
forkey, valuein {**old_dict, **self.__dict__}.items():
setattr(self, key, value)
object.__setattr__(self, "__pydantic_fields_set__", fields_set)
forkeyinself.__sqlmodel_relationships__:
value=data.get(key, Undefined)
ifvalueisnotUndefined:
setattr(self, key, value)

This mirrors the existing pattern used by sqlmodel_validate() (the model_validate() path) which already validates table models successfully.

Addressing Prior Concerns

"SQLAlchemy needs to assign values after instantiation" (#52)

The concern raised in #52 was that relationships need to be assignable after construction, so validation can't run on __init__.

Relationships are not part of model_fields — they live in __sqlmodel_relationships__ and are handled separately, outside of Pydantic validation. validate_python() never sees or validates relationship attributes. Both sides of a bidirectional relationship can be created independently, exactly as before:

fromsqlmodelimportSQLModel, Field, Relationship, Session, create_engineclassTeam(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strheroes: list["Hero"] =Relationship(back_populates="team")
classHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strteam_id: int|None=Field(default=None, foreign_key="team.id")
team: Team|None=Relationship(back_populates="heroes")
# Create each side independently — no relationship passedteam=Team(name="Preventers")
hero=Hero(name="Deadpond")
# Assign relationship after constructionhero.team=teamengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
withSession(engine) assession:
session.add(hero)
session.commit()
session.refresh(hero)
print(f"{hero.name}'s team: {hero.team.name}")
Deadpond's team: Preventers

Performance on ORM Reads

Validation does not run when loading from the database. SQLAlchemy does not call __init__ when hydrating instances from query results (SQLAlchemy docs: Constructors and Object Initialization). This is unchanged, as it is safe to assume that data loaded from the database is valid.

To verify, here is a test that writes invalid data directly to the database and confirms it loads without triggering validation:

frompydanticimportfield_validatorfromsqlmodelimportSQLModel, Field, Session, create_engine, selectclassHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: str@field_validator("name")@classmethoddefname_must_be_short(cls, v: str) ->str:
iflen(v) >5:
raiseValueError("too long")
returnvengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
# Insert valid data through the modelwithSession(engine) assession:
session.add(Hero(name="short"))
session.commit()
# Write invalid data directly to the database, bypassing the modelwithengine.connect() asconn:
conn.execute(
Hero.__table__.update()
.where(Hero.__table__.c.id==1)
.values(name="this is way too long")
)
conn.commit()
# Load from database — no validation runs, invalid data loads finewithSession(engine) assession:
loaded=session.exec(select(Hero)).first()
print(f"Loaded from DB: {loaded.name!r}")
Loaded from DB: 'this is way too long'

Breaking Change

This could represent a behavior change for code that previously constructed table=True models with invalid data.

Related Issues and PRs

  • #52 — SQLModel doesn't raise ValidationError
  • #453 — Why does a SQLModel class with table=True not validate data?
  • #134 — Pydantic Validators does not raise ValueError if conditions are not met
  • #1041 — Ensure that type checks are executed when setting table=True
  • #227 — Class Initialisation Validation Kwarg

Thank you for reviewing!

Enable Pydantic validation on table=True model __init__, matching the
existing behavior of model_validate(). Validation does not run on ORM
loads from the database — SQLAlchemy does not call __init__ when
hydrating from query results.

@mahdirajaeemahdirajaee left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a significant behavioral change — table models now run Pydantic validation on __init__ instead of bypassing it via sqlmodel_table_construct. The key insight is that validate_python(data, self_instance=self) is called unconditionally for both table and non-table models now, which means field validators, model validators, and type coercion all fire during construction. The test suite additions are excellent and cover the critical scenarios: field_validator, model_validator (both before/after modes), BeforeValidator via Annotated, and crucially test_validation_does_not_run_on_orm_load which verifies that loading from the database still bypasses validation (important for not breaking existing data that might not pass newer validators).

The removal of sqlmodel_table_construct is the right call since it was essentially a copy of Pydantic's model_construct() that skipped validation — which was the original bug. One thing to watch: this is a breaking change for users who relied on being able to instantiate table models with missing required fields (the test_not_allow_instantiation_without_arguments test makes this explicit). The old test test_allow_instantiation_without_arguments previously passed with Item() where name: str had no default — that will now raise ValidationError. Projects doing item = Item(); item.name = "..." will need to migrate to Item(name="...").

The sqlmodel_init branching for table vs non-table models post-validation is sensible: non-table models merge dicts directly, while table models use setattr to trigger SQLAlchemy instrumentation and manually restore __pydantic_fields_set__. Worth confirming that the fields_set save/restore around the setattr loop doesn't cause issues if a setattr triggers a SQLAlchemy event that modifies __pydantic_fields_set__ internally, though that seems unlikely in practice.

@svlandeg

This comment was marked as off-topic.

@parthmishra

This comment was marked as off-topic.

@Graeme22

Graeme22 commented May 5, 2026

Copy link
Copy Markdown

This is great work and desperately needed! I hope @tiangolo will consider these changes. Might have to bump the middle version number, though the breakage this will cause will mostly be "good" in that it will catch incorrect code that was silently passing before.

In my projects I have a lot of code like:

classHeroValidate(SQLModel):
age: intclassHero(HeroValidate, table=True):
id: int=Field(...)
hero=HeroValidate(42)
hero_db=Hero(**hero.model_dump())
session.add(hero_db)

This is of course unwieldy, and it's quite clear many users have been bitten by this behavior at one point or another, so it should be a high priority to fix.

@clstaudt

Copy link
Copy Markdown

this breaks the contract that "a SQLModel model is also a Pydantic model".

Exactly. I was very surprised to realise that this is not actually the case. I hope this can be corrected soon.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@ClayGendron@svlandeg@parthmishra@Graeme22@clstaudt@mahdirajaee@tiangolo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Validate SQLModel on all Non-Database Sourced Data - #1823

Open
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction
Open

Validate SQLModel on all Non-Database Sourced Data#1823
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction

Conversation

@ClayGendron

Copy link
Copy Markdown

As noted by other issues and pull requests, when setting table=True in a SQLModel, Pydantic validation does not run, and this breaks the contract that "a SQLModel model is also a Pydantic model". This PR builds on prior ones and also hopes to address concerns with changing the intentional validation bypass for table models.

First, for those new to this issue, here is an example of how SQLModels behave differently when they are a table:

frompydanticimportBaseModel, ValidationErrorfromsqlmodelimportSQLModel, FieldclassHeroBase(BaseModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLBase(SQLModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLTable(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strage: inttry:
HeroBase(name="Deadpond", age="not an int")
print("HeroBase: created with invalid data!")
exceptValidationError:
print("HeroBase: ValidationError raised")
try:
HeroSQLBase(name="Deadpond", age="not an int")
print("HeroSQLBase: created with invalid data!")
exceptValidationError:
print("HeroSQLBase: ValidationError raised")
try:
HeroSQLTable(name="Deadpond", age="not an int")
print("HeroSQLTable: created with invalid data!")
exceptValidationError:
print("HeroSQLTable: ValidationError raised")
HeroBase: ValidationError raised
HeroSQLBase: ValidationError raised
HeroSQLTable: created with invalid data!

The same is true for @field_validator and @model_validator. Both will be silently ignored when table=True.

Use Case

I am building a library that includes a base SQLModel class (without table=True) that has validators to normalize and populate data. Downstream developers using my library would then create their own table=True class that inherits from the base:

# ---- library code ----importhashlibimportposixpathimportuuidfrompydanticimportfield_validator, model_validatorfromsqlmodelimportSQLModel, FieldclassDocumentBase(SQLModel):
id: str=Field(
default_factory=lambda: str(uuid.uuid4()), max_length=256, primary_key=True
)
path: strcontent: strcontent_hash: str=""@field_validator("path")@classmethoddefnormalize_path(cls, v: str) ->str:
ifnotv.startswith("/"):
v="/"+vreturnposixpath.normpath(v)
@model_validator(mode="after")defcompute_content_hash(self) ->"DocumentBase":
self.content_hash=hashlib.sha256(self.content.encode()).hexdigest()
returnself# ---- downstream developer code ----classDocument(DocumentBase, table=True):
project_field: str|None=None

The base class works correctly on its own, but it can't hold the downstream project_field:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
DocumentBase(
path='/path', # normalizedcontent='hello',
content_hash='2cf24db...', # computed# project_field missing!
)

But when a downstream developer inherits with table=True, both validators are silently skipped:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
Document(
path='not_normalized/../path', # not normalized!content='hello',
content_hash='', # not computed!project_field='important info!',
)

My library must rely on validation from the custom defined inherited class, but it will not work out of the box. My issue could be resolved with the solution described in the multiple models doc, but that approach would mean I would be asking developers to create twice as many classes, one for validation and one for table mapping. Using SQLModel was chosen for this project as it promised to provide a unified model between Pydantic and SQLAlchemy, which in my case, means any initialized model derived from DocumentBase is valid, both in python and in the database.

The Change

The change is in sqlmodel_init() in _compat.py. Previously, table models called sqlmodel_table_construct() which skips validation entirely. Now, all models go through validate_python(), and table models do a post-validation step to re-trigger SQLAlchemy instrumentation via setattr:

defsqlmodel_init(*, self: "SQLModel", data: dict[str, Any]) ->None:
old_dict=self.__dict__.copy()
self.__pydantic_validator__.validate_python(
data,
self_instance=self,
)
ifnotis_table_model_class(self.__class__):
object.__setattr__(
self,
"__dict__",
{**old_dict, **self.__dict__},
)
else:
fields_set=self.__pydantic_fields_set__.copy()
forkey, valuein {**old_dict, **self.__dict__}.items():
setattr(self, key, value)
object.__setattr__(self, "__pydantic_fields_set__", fields_set)
forkeyinself.__sqlmodel_relationships__:
value=data.get(key, Undefined)
ifvalueisnotUndefined:
setattr(self, key, value)

This mirrors the existing pattern used by sqlmodel_validate() (the model_validate() path) which already validates table models successfully.

Addressing Prior Concerns

"SQLAlchemy needs to assign values after instantiation" (#52)

The concern raised in #52 was that relationships need to be assignable after construction, so validation can't run on __init__.

Relationships are not part of model_fields — they live in __sqlmodel_relationships__ and are handled separately, outside of Pydantic validation. validate_python() never sees or validates relationship attributes. Both sides of a bidirectional relationship can be created independently, exactly as before:

fromsqlmodelimportSQLModel, Field, Relationship, Session, create_engineclassTeam(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strheroes: list["Hero"] =Relationship(back_populates="team")
classHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strteam_id: int|None=Field(default=None, foreign_key="team.id")
team: Team|None=Relationship(back_populates="heroes")
# Create each side independently — no relationship passedteam=Team(name="Preventers")
hero=Hero(name="Deadpond")
# Assign relationship after constructionhero.team=teamengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
withSession(engine) assession:
session.add(hero)
session.commit()
session.refresh(hero)
print(f"{hero.name}'s team: {hero.team.name}")
Deadpond's team: Preventers

Performance on ORM Reads

Validation does not run when loading from the database. SQLAlchemy does not call __init__ when hydrating instances from query results (SQLAlchemy docs: Constructors and Object Initialization). This is unchanged, as it is safe to assume that data loaded from the database is valid.

To verify, here is a test that writes invalid data directly to the database and confirms it loads without triggering validation:

frompydanticimportfield_validatorfromsqlmodelimportSQLModel, Field, Session, create_engine, selectclassHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: str@field_validator("name")@classmethoddefname_must_be_short(cls, v: str) ->str:
iflen(v) >5:
raiseValueError("too long")
returnvengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
# Insert valid data through the modelwithSession(engine) assession:
session.add(Hero(name="short"))
session.commit()
# Write invalid data directly to the database, bypassing the modelwithengine.connect() asconn:
conn.execute(
Hero.__table__.update()
.where(Hero.__table__.c.id==1)
.values(name="this is way too long")
)
conn.commit()
# Load from database — no validation runs, invalid data loads finewithSession(engine) assession:
loaded=session.exec(select(Hero)).first()
print(f"Loaded from DB: {loaded.name!r}")
Loaded from DB: 'this is way too long'

Breaking Change

This could represent a behavior change for code that previously constructed table=True models with invalid data.

Related Issues and PRs

  • #52 — SQLModel doesn't raise ValidationError
  • #453 — Why does a SQLModel class with table=True not validate data?
  • #134 — Pydantic Validators does not raise ValueError if conditions are not met
  • #1041 — Ensure that type checks are executed when setting table=True
  • #227 — Class Initialisation Validation Kwarg

Thank you for reviewing!

Enable Pydantic validation on table=True model __init__, matching the
existing behavior of model_validate(). Validation does not run on ORM
loads from the database — SQLAlchemy does not call __init__ when
hydrating from query results.

@mahdirajaeemahdirajaee left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a significant behavioral change — table models now run Pydantic validation on __init__ instead of bypassing it via sqlmodel_table_construct. The key insight is that validate_python(data, self_instance=self) is called unconditionally for both table and non-table models now, which means field validators, model validators, and type coercion all fire during construction. The test suite additions are excellent and cover the critical scenarios: field_validator, model_validator (both before/after modes), BeforeValidator via Annotated, and crucially test_validation_does_not_run_on_orm_load which verifies that loading from the database still bypasses validation (important for not breaking existing data that might not pass newer validators).

The removal of sqlmodel_table_construct is the right call since it was essentially a copy of Pydantic's model_construct() that skipped validation — which was the original bug. One thing to watch: this is a breaking change for users who relied on being able to instantiate table models with missing required fields (the test_not_allow_instantiation_without_arguments test makes this explicit). The old test test_allow_instantiation_without_arguments previously passed with Item() where name: str had no default — that will now raise ValidationError. Projects doing item = Item(); item.name = "..." will need to migrate to Item(name="...").

The sqlmodel_init branching for table vs non-table models post-validation is sensible: non-table models merge dicts directly, while table models use setattr to trigger SQLAlchemy instrumentation and manually restore __pydantic_fields_set__. Worth confirming that the fields_set save/restore around the setattr loop doesn't cause issues if a setattr triggers a SQLAlchemy event that modifies __pydantic_fields_set__ internally, though that seems unlikely in practice.

@svlandeg

This comment was marked as off-topic.

@parthmishra

This comment was marked as off-topic.

@Graeme22

Graeme22 commented May 5, 2026

Copy link
Copy Markdown

This is great work and desperately needed! I hope @tiangolo will consider these changes. Might have to bump the middle version number, though the breakage this will cause will mostly be "good" in that it will catch incorrect code that was silently passing before.

In my projects I have a lot of code like:

classHeroValidate(SQLModel):
age: intclassHero(HeroValidate, table=True):
id: int=Field(...)
hero=HeroValidate(42)
hero_db=Hero(**hero.model_dump())
session.add(hero_db)

This is of course unwieldy, and it's quite clear many users have been bitten by this behavior at one point or another, so it should be a high priority to fix.

@clstaudt

Copy link
Copy Markdown

this breaks the contract that "a SQLModel model is also a Pydantic model".

Exactly. I was very surprised to realise that this is not actually the case. I hope this can be corrected soon.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@ClayGendron@svlandeg@parthmishra@Graeme22@clstaudt@mahdirajaee@tiangolo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Validate SQLModel on all Non-Database Sourced Data - #1823

Open
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction
Open

Validate SQLModel on all Non-Database Sourced Data#1823
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction

Conversation

@ClayGendron

Copy link
Copy Markdown

As noted by other issues and pull requests, when setting table=True in a SQLModel, Pydantic validation does not run, and this breaks the contract that "a SQLModel model is also a Pydantic model". This PR builds on prior ones and also hopes to address concerns with changing the intentional validation bypass for table models.

First, for those new to this issue, here is an example of how SQLModels behave differently when they are a table:

frompydanticimportBaseModel, ValidationErrorfromsqlmodelimportSQLModel, FieldclassHeroBase(BaseModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLBase(SQLModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLTable(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strage: inttry:
HeroBase(name="Deadpond", age="not an int")
print("HeroBase: created with invalid data!")
exceptValidationError:
print("HeroBase: ValidationError raised")
try:
HeroSQLBase(name="Deadpond", age="not an int")
print("HeroSQLBase: created with invalid data!")
exceptValidationError:
print("HeroSQLBase: ValidationError raised")
try:
HeroSQLTable(name="Deadpond", age="not an int")
print("HeroSQLTable: created with invalid data!")
exceptValidationError:
print("HeroSQLTable: ValidationError raised")
HeroBase: ValidationError raised
HeroSQLBase: ValidationError raised
HeroSQLTable: created with invalid data!

The same is true for @field_validator and @model_validator. Both will be silently ignored when table=True.

Use Case

I am building a library that includes a base SQLModel class (without table=True) that has validators to normalize and populate data. Downstream developers using my library would then create their own table=True class that inherits from the base:

# ---- library code ----importhashlibimportposixpathimportuuidfrompydanticimportfield_validator, model_validatorfromsqlmodelimportSQLModel, FieldclassDocumentBase(SQLModel):
id: str=Field(
default_factory=lambda: str(uuid.uuid4()), max_length=256, primary_key=True
)
path: strcontent: strcontent_hash: str=""@field_validator("path")@classmethoddefnormalize_path(cls, v: str) ->str:
ifnotv.startswith("/"):
v="/"+vreturnposixpath.normpath(v)
@model_validator(mode="after")defcompute_content_hash(self) ->"DocumentBase":
self.content_hash=hashlib.sha256(self.content.encode()).hexdigest()
returnself# ---- downstream developer code ----classDocument(DocumentBase, table=True):
project_field: str|None=None

The base class works correctly on its own, but it can't hold the downstream project_field:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
DocumentBase(
path='/path', # normalizedcontent='hello',
content_hash='2cf24db...', # computed# project_field missing!
)

But when a downstream developer inherits with table=True, both validators are silently skipped:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
Document(
path='not_normalized/../path', # not normalized!content='hello',
content_hash='', # not computed!project_field='important info!',
)

My library must rely on validation from the custom defined inherited class, but it will not work out of the box. My issue could be resolved with the solution described in the multiple models doc, but that approach would mean I would be asking developers to create twice as many classes, one for validation and one for table mapping. Using SQLModel was chosen for this project as it promised to provide a unified model between Pydantic and SQLAlchemy, which in my case, means any initialized model derived from DocumentBase is valid, both in python and in the database.

The Change

The change is in sqlmodel_init() in _compat.py. Previously, table models called sqlmodel_table_construct() which skips validation entirely. Now, all models go through validate_python(), and table models do a post-validation step to re-trigger SQLAlchemy instrumentation via setattr:

defsqlmodel_init(*, self: "SQLModel", data: dict[str, Any]) ->None:
old_dict=self.__dict__.copy()
self.__pydantic_validator__.validate_python(
data,
self_instance=self,
)
ifnotis_table_model_class(self.__class__):
object.__setattr__(
self,
"__dict__",
{**old_dict, **self.__dict__},
)
else:
fields_set=self.__pydantic_fields_set__.copy()
forkey, valuein {**old_dict, **self.__dict__}.items():
setattr(self, key, value)
object.__setattr__(self, "__pydantic_fields_set__", fields_set)
forkeyinself.__sqlmodel_relationships__:
value=data.get(key, Undefined)
ifvalueisnotUndefined:
setattr(self, key, value)

This mirrors the existing pattern used by sqlmodel_validate() (the model_validate() path) which already validates table models successfully.

Addressing Prior Concerns

"SQLAlchemy needs to assign values after instantiation" (#52)

The concern raised in #52 was that relationships need to be assignable after construction, so validation can't run on __init__.

Relationships are not part of model_fields — they live in __sqlmodel_relationships__ and are handled separately, outside of Pydantic validation. validate_python() never sees or validates relationship attributes. Both sides of a bidirectional relationship can be created independently, exactly as before:

fromsqlmodelimportSQLModel, Field, Relationship, Session, create_engineclassTeam(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strheroes: list["Hero"] =Relationship(back_populates="team")
classHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strteam_id: int|None=Field(default=None, foreign_key="team.id")
team: Team|None=Relationship(back_populates="heroes")
# Create each side independently — no relationship passedteam=Team(name="Preventers")
hero=Hero(name="Deadpond")
# Assign relationship after constructionhero.team=teamengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
withSession(engine) assession:
session.add(hero)
session.commit()
session.refresh(hero)
print(f"{hero.name}'s team: {hero.team.name}")
Deadpond's team: Preventers

Performance on ORM Reads

Validation does not run when loading from the database. SQLAlchemy does not call __init__ when hydrating instances from query results (SQLAlchemy docs: Constructors and Object Initialization). This is unchanged, as it is safe to assume that data loaded from the database is valid.

To verify, here is a test that writes invalid data directly to the database and confirms it loads without triggering validation:

frompydanticimportfield_validatorfromsqlmodelimportSQLModel, Field, Session, create_engine, selectclassHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: str@field_validator("name")@classmethoddefname_must_be_short(cls, v: str) ->str:
iflen(v) >5:
raiseValueError("too long")
returnvengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
# Insert valid data through the modelwithSession(engine) assession:
session.add(Hero(name="short"))
session.commit()
# Write invalid data directly to the database, bypassing the modelwithengine.connect() asconn:
conn.execute(
Hero.__table__.update()
.where(Hero.__table__.c.id==1)
.values(name="this is way too long")
)
conn.commit()
# Load from database — no validation runs, invalid data loads finewithSession(engine) assession:
loaded=session.exec(select(Hero)).first()
print(f"Loaded from DB: {loaded.name!r}")
Loaded from DB: 'this is way too long'

Breaking Change

This could represent a behavior change for code that previously constructed table=True models with invalid data.

Related Issues and PRs

  • #52 — SQLModel doesn't raise ValidationError
  • #453 — Why does a SQLModel class with table=True not validate data?
  • #134 — Pydantic Validators does not raise ValueError if conditions are not met
  • #1041 — Ensure that type checks are executed when setting table=True
  • #227 — Class Initialisation Validation Kwarg

Thank you for reviewing!

Enable Pydantic validation on table=True model __init__, matching the
existing behavior of model_validate(). Validation does not run on ORM
loads from the database — SQLAlchemy does not call __init__ when
hydrating from query results.

@mahdirajaeemahdirajaee left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a significant behavioral change — table models now run Pydantic validation on __init__ instead of bypassing it via sqlmodel_table_construct. The key insight is that validate_python(data, self_instance=self) is called unconditionally for both table and non-table models now, which means field validators, model validators, and type coercion all fire during construction. The test suite additions are excellent and cover the critical scenarios: field_validator, model_validator (both before/after modes), BeforeValidator via Annotated, and crucially test_validation_does_not_run_on_orm_load which verifies that loading from the database still bypasses validation (important for not breaking existing data that might not pass newer validators).

The removal of sqlmodel_table_construct is the right call since it was essentially a copy of Pydantic's model_construct() that skipped validation — which was the original bug. One thing to watch: this is a breaking change for users who relied on being able to instantiate table models with missing required fields (the test_not_allow_instantiation_without_arguments test makes this explicit). The old test test_allow_instantiation_without_arguments previously passed with Item() where name: str had no default — that will now raise ValidationError. Projects doing item = Item(); item.name = "..." will need to migrate to Item(name="...").

The sqlmodel_init branching for table vs non-table models post-validation is sensible: non-table models merge dicts directly, while table models use setattr to trigger SQLAlchemy instrumentation and manually restore __pydantic_fields_set__. Worth confirming that the fields_set save/restore around the setattr loop doesn't cause issues if a setattr triggers a SQLAlchemy event that modifies __pydantic_fields_set__ internally, though that seems unlikely in practice.

@svlandeg

This comment was marked as off-topic.

@parthmishra

This comment was marked as off-topic.

@Graeme22

Graeme22 commented May 5, 2026

Copy link
Copy Markdown

This is great work and desperately needed! I hope @tiangolo will consider these changes. Might have to bump the middle version number, though the breakage this will cause will mostly be "good" in that it will catch incorrect code that was silently passing before.

In my projects I have a lot of code like:

classHeroValidate(SQLModel):
age: intclassHero(HeroValidate, table=True):
id: int=Field(...)
hero=HeroValidate(42)
hero_db=Hero(**hero.model_dump())
session.add(hero_db)

This is of course unwieldy, and it's quite clear many users have been bitten by this behavior at one point or another, so it should be a high priority to fix.

@clstaudt

Copy link
Copy Markdown

this breaks the contract that "a SQLModel model is also a Pydantic model".

Exactly. I was very surprised to realise that this is not actually the case. I hope this can be corrected soon.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@ClayGendron@svlandeg@parthmishra@Graeme22@clstaudt@mahdirajaee@tiangolo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Validate SQLModel on all Non-Database Sourced Data - #1823

Open
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction
Open

Validate SQLModel on all Non-Database Sourced Data#1823
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction

Conversation

@ClayGendron

Copy link
Copy Markdown

As noted by other issues and pull requests, when setting table=True in a SQLModel, Pydantic validation does not run, and this breaks the contract that "a SQLModel model is also a Pydantic model". This PR builds on prior ones and also hopes to address concerns with changing the intentional validation bypass for table models.

First, for those new to this issue, here is an example of how SQLModels behave differently when they are a table:

frompydanticimportBaseModel, ValidationErrorfromsqlmodelimportSQLModel, FieldclassHeroBase(BaseModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLBase(SQLModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLTable(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strage: inttry:
HeroBase(name="Deadpond", age="not an int")
print("HeroBase: created with invalid data!")
exceptValidationError:
print("HeroBase: ValidationError raised")
try:
HeroSQLBase(name="Deadpond", age="not an int")
print("HeroSQLBase: created with invalid data!")
exceptValidationError:
print("HeroSQLBase: ValidationError raised")
try:
HeroSQLTable(name="Deadpond", age="not an int")
print("HeroSQLTable: created with invalid data!")
exceptValidationError:
print("HeroSQLTable: ValidationError raised")
HeroBase: ValidationError raised
HeroSQLBase: ValidationError raised
HeroSQLTable: created with invalid data!

The same is true for @field_validator and @model_validator. Both will be silently ignored when table=True.

Use Case

I am building a library that includes a base SQLModel class (without table=True) that has validators to normalize and populate data. Downstream developers using my library would then create their own table=True class that inherits from the base:

# ---- library code ----importhashlibimportposixpathimportuuidfrompydanticimportfield_validator, model_validatorfromsqlmodelimportSQLModel, FieldclassDocumentBase(SQLModel):
id: str=Field(
default_factory=lambda: str(uuid.uuid4()), max_length=256, primary_key=True
)
path: strcontent: strcontent_hash: str=""@field_validator("path")@classmethoddefnormalize_path(cls, v: str) ->str:
ifnotv.startswith("/"):
v="/"+vreturnposixpath.normpath(v)
@model_validator(mode="after")defcompute_content_hash(self) ->"DocumentBase":
self.content_hash=hashlib.sha256(self.content.encode()).hexdigest()
returnself# ---- downstream developer code ----classDocument(DocumentBase, table=True):
project_field: str|None=None

The base class works correctly on its own, but it can't hold the downstream project_field:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
DocumentBase(
path='/path', # normalizedcontent='hello',
content_hash='2cf24db...', # computed# project_field missing!
)

But when a downstream developer inherits with table=True, both validators are silently skipped:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
Document(
path='not_normalized/../path', # not normalized!content='hello',
content_hash='', # not computed!project_field='important info!',
)

My library must rely on validation from the custom defined inherited class, but it will not work out of the box. My issue could be resolved with the solution described in the multiple models doc, but that approach would mean I would be asking developers to create twice as many classes, one for validation and one for table mapping. Using SQLModel was chosen for this project as it promised to provide a unified model between Pydantic and SQLAlchemy, which in my case, means any initialized model derived from DocumentBase is valid, both in python and in the database.

The Change

The change is in sqlmodel_init() in _compat.py. Previously, table models called sqlmodel_table_construct() which skips validation entirely. Now, all models go through validate_python(), and table models do a post-validation step to re-trigger SQLAlchemy instrumentation via setattr:

defsqlmodel_init(*, self: "SQLModel", data: dict[str, Any]) ->None:
old_dict=self.__dict__.copy()
self.__pydantic_validator__.validate_python(
data,
self_instance=self,
)
ifnotis_table_model_class(self.__class__):
object.__setattr__(
self,
"__dict__",
{**old_dict, **self.__dict__},
)
else:
fields_set=self.__pydantic_fields_set__.copy()
forkey, valuein {**old_dict, **self.__dict__}.items():
setattr(self, key, value)
object.__setattr__(self, "__pydantic_fields_set__", fields_set)
forkeyinself.__sqlmodel_relationships__:
value=data.get(key, Undefined)
ifvalueisnotUndefined:
setattr(self, key, value)

This mirrors the existing pattern used by sqlmodel_validate() (the model_validate() path) which already validates table models successfully.

Addressing Prior Concerns

"SQLAlchemy needs to assign values after instantiation" (#52)

The concern raised in #52 was that relationships need to be assignable after construction, so validation can't run on __init__.

Relationships are not part of model_fields — they live in __sqlmodel_relationships__ and are handled separately, outside of Pydantic validation. validate_python() never sees or validates relationship attributes. Both sides of a bidirectional relationship can be created independently, exactly as before:

fromsqlmodelimportSQLModel, Field, Relationship, Session, create_engineclassTeam(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strheroes: list["Hero"] =Relationship(back_populates="team")
classHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strteam_id: int|None=Field(default=None, foreign_key="team.id")
team: Team|None=Relationship(back_populates="heroes")
# Create each side independently — no relationship passedteam=Team(name="Preventers")
hero=Hero(name="Deadpond")
# Assign relationship after constructionhero.team=teamengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
withSession(engine) assession:
session.add(hero)
session.commit()
session.refresh(hero)
print(f"{hero.name}'s team: {hero.team.name}")
Deadpond's team: Preventers

Performance on ORM Reads

Validation does not run when loading from the database. SQLAlchemy does not call __init__ when hydrating instances from query results (SQLAlchemy docs: Constructors and Object Initialization). This is unchanged, as it is safe to assume that data loaded from the database is valid.

To verify, here is a test that writes invalid data directly to the database and confirms it loads without triggering validation:

frompydanticimportfield_validatorfromsqlmodelimportSQLModel, Field, Session, create_engine, selectclassHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: str@field_validator("name")@classmethoddefname_must_be_short(cls, v: str) ->str:
iflen(v) >5:
raiseValueError("too long")
returnvengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
# Insert valid data through the modelwithSession(engine) assession:
session.add(Hero(name="short"))
session.commit()
# Write invalid data directly to the database, bypassing the modelwithengine.connect() asconn:
conn.execute(
Hero.__table__.update()
.where(Hero.__table__.c.id==1)
.values(name="this is way too long")
)
conn.commit()
# Load from database — no validation runs, invalid data loads finewithSession(engine) assession:
loaded=session.exec(select(Hero)).first()
print(f"Loaded from DB: {loaded.name!r}")
Loaded from DB: 'this is way too long'

Breaking Change

This could represent a behavior change for code that previously constructed table=True models with invalid data.

Related Issues and PRs

  • #52 — SQLModel doesn't raise ValidationError
  • #453 — Why does a SQLModel class with table=True not validate data?
  • #134 — Pydantic Validators does not raise ValueError if conditions are not met
  • #1041 — Ensure that type checks are executed when setting table=True
  • #227 — Class Initialisation Validation Kwarg

Thank you for reviewing!

Enable Pydantic validation on table=True model __init__, matching the
existing behavior of model_validate(). Validation does not run on ORM
loads from the database — SQLAlchemy does not call __init__ when
hydrating from query results.

@mahdirajaeemahdirajaee left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a significant behavioral change — table models now run Pydantic validation on __init__ instead of bypassing it via sqlmodel_table_construct. The key insight is that validate_python(data, self_instance=self) is called unconditionally for both table and non-table models now, which means field validators, model validators, and type coercion all fire during construction. The test suite additions are excellent and cover the critical scenarios: field_validator, model_validator (both before/after modes), BeforeValidator via Annotated, and crucially test_validation_does_not_run_on_orm_load which verifies that loading from the database still bypasses validation (important for not breaking existing data that might not pass newer validators).

The removal of sqlmodel_table_construct is the right call since it was essentially a copy of Pydantic's model_construct() that skipped validation — which was the original bug. One thing to watch: this is a breaking change for users who relied on being able to instantiate table models with missing required fields (the test_not_allow_instantiation_without_arguments test makes this explicit). The old test test_allow_instantiation_without_arguments previously passed with Item() where name: str had no default — that will now raise ValidationError. Projects doing item = Item(); item.name = "..." will need to migrate to Item(name="...").

The sqlmodel_init branching for table vs non-table models post-validation is sensible: non-table models merge dicts directly, while table models use setattr to trigger SQLAlchemy instrumentation and manually restore __pydantic_fields_set__. Worth confirming that the fields_set save/restore around the setattr loop doesn't cause issues if a setattr triggers a SQLAlchemy event that modifies __pydantic_fields_set__ internally, though that seems unlikely in practice.

@svlandeg

This comment was marked as off-topic.

@parthmishra

This comment was marked as off-topic.

@Graeme22

Graeme22 commented May 5, 2026

Copy link
Copy Markdown

This is great work and desperately needed! I hope @tiangolo will consider these changes. Might have to bump the middle version number, though the breakage this will cause will mostly be "good" in that it will catch incorrect code that was silently passing before.

In my projects I have a lot of code like:

classHeroValidate(SQLModel):
age: intclassHero(HeroValidate, table=True):
id: int=Field(...)
hero=HeroValidate(42)
hero_db=Hero(**hero.model_dump())
session.add(hero_db)

This is of course unwieldy, and it's quite clear many users have been bitten by this behavior at one point or another, so it should be a high priority to fix.

@clstaudt

Copy link
Copy Markdown

this breaks the contract that "a SQLModel model is also a Pydantic model".

Exactly. I was very surprised to realise that this is not actually the case. I hope this can be corrected soon.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@ClayGendron@svlandeg@parthmishra@Graeme22@clstaudt@mahdirajaee@tiangolo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Validate SQLModel on all Non-Database Sourced Data - #1823

Open
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction
Open

Validate SQLModel on all Non-Database Sourced Data#1823
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction

Conversation

@ClayGendron

Copy link
Copy Markdown

As noted by other issues and pull requests, when setting table=True in a SQLModel, Pydantic validation does not run, and this breaks the contract that "a SQLModel model is also a Pydantic model". This PR builds on prior ones and also hopes to address concerns with changing the intentional validation bypass for table models.

First, for those new to this issue, here is an example of how SQLModels behave differently when they are a table:

frompydanticimportBaseModel, ValidationErrorfromsqlmodelimportSQLModel, FieldclassHeroBase(BaseModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLBase(SQLModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLTable(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strage: inttry:
HeroBase(name="Deadpond", age="not an int")
print("HeroBase: created with invalid data!")
exceptValidationError:
print("HeroBase: ValidationError raised")
try:
HeroSQLBase(name="Deadpond", age="not an int")
print("HeroSQLBase: created with invalid data!")
exceptValidationError:
print("HeroSQLBase: ValidationError raised")
try:
HeroSQLTable(name="Deadpond", age="not an int")
print("HeroSQLTable: created with invalid data!")
exceptValidationError:
print("HeroSQLTable: ValidationError raised")
HeroBase: ValidationError raised
HeroSQLBase: ValidationError raised
HeroSQLTable: created with invalid data!

The same is true for @field_validator and @model_validator. Both will be silently ignored when table=True.

Use Case

I am building a library that includes a base SQLModel class (without table=True) that has validators to normalize and populate data. Downstream developers using my library would then create their own table=True class that inherits from the base:

# ---- library code ----importhashlibimportposixpathimportuuidfrompydanticimportfield_validator, model_validatorfromsqlmodelimportSQLModel, FieldclassDocumentBase(SQLModel):
id: str=Field(
default_factory=lambda: str(uuid.uuid4()), max_length=256, primary_key=True
)
path: strcontent: strcontent_hash: str=""@field_validator("path")@classmethoddefnormalize_path(cls, v: str) ->str:
ifnotv.startswith("/"):
v="/"+vreturnposixpath.normpath(v)
@model_validator(mode="after")defcompute_content_hash(self) ->"DocumentBase":
self.content_hash=hashlib.sha256(self.content.encode()).hexdigest()
returnself# ---- downstream developer code ----classDocument(DocumentBase, table=True):
project_field: str|None=None

The base class works correctly on its own, but it can't hold the downstream project_field:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
DocumentBase(
path='/path', # normalizedcontent='hello',
content_hash='2cf24db...', # computed# project_field missing!
)

But when a downstream developer inherits with table=True, both validators are silently skipped:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
Document(
path='not_normalized/../path', # not normalized!content='hello',
content_hash='', # not computed!project_field='important info!',
)

My library must rely on validation from the custom defined inherited class, but it will not work out of the box. My issue could be resolved with the solution described in the multiple models doc, but that approach would mean I would be asking developers to create twice as many classes, one for validation and one for table mapping. Using SQLModel was chosen for this project as it promised to provide a unified model between Pydantic and SQLAlchemy, which in my case, means any initialized model derived from DocumentBase is valid, both in python and in the database.

The Change

The change is in sqlmodel_init() in _compat.py. Previously, table models called sqlmodel_table_construct() which skips validation entirely. Now, all models go through validate_python(), and table models do a post-validation step to re-trigger SQLAlchemy instrumentation via setattr:

defsqlmodel_init(*, self: "SQLModel", data: dict[str, Any]) ->None:
old_dict=self.__dict__.copy()
self.__pydantic_validator__.validate_python(
data,
self_instance=self,
)
ifnotis_table_model_class(self.__class__):
object.__setattr__(
self,
"__dict__",
{**old_dict, **self.__dict__},
)
else:
fields_set=self.__pydantic_fields_set__.copy()
forkey, valuein {**old_dict, **self.__dict__}.items():
setattr(self, key, value)
object.__setattr__(self, "__pydantic_fields_set__", fields_set)
forkeyinself.__sqlmodel_relationships__:
value=data.get(key, Undefined)
ifvalueisnotUndefined:
setattr(self, key, value)

This mirrors the existing pattern used by sqlmodel_validate() (the model_validate() path) which already validates table models successfully.

Addressing Prior Concerns

"SQLAlchemy needs to assign values after instantiation" (#52)

The concern raised in #52 was that relationships need to be assignable after construction, so validation can't run on __init__.

Relationships are not part of model_fields — they live in __sqlmodel_relationships__ and are handled separately, outside of Pydantic validation. validate_python() never sees or validates relationship attributes. Both sides of a bidirectional relationship can be created independently, exactly as before:

fromsqlmodelimportSQLModel, Field, Relationship, Session, create_engineclassTeam(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strheroes: list["Hero"] =Relationship(back_populates="team")
classHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strteam_id: int|None=Field(default=None, foreign_key="team.id")
team: Team|None=Relationship(back_populates="heroes")
# Create each side independently — no relationship passedteam=Team(name="Preventers")
hero=Hero(name="Deadpond")
# Assign relationship after constructionhero.team=teamengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
withSession(engine) assession:
session.add(hero)
session.commit()
session.refresh(hero)
print(f"{hero.name}'s team: {hero.team.name}")
Deadpond's team: Preventers

Performance on ORM Reads

Validation does not run when loading from the database. SQLAlchemy does not call __init__ when hydrating instances from query results (SQLAlchemy docs: Constructors and Object Initialization). This is unchanged, as it is safe to assume that data loaded from the database is valid.

To verify, here is a test that writes invalid data directly to the database and confirms it loads without triggering validation:

frompydanticimportfield_validatorfromsqlmodelimportSQLModel, Field, Session, create_engine, selectclassHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: str@field_validator("name")@classmethoddefname_must_be_short(cls, v: str) ->str:
iflen(v) >5:
raiseValueError("too long")
returnvengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
# Insert valid data through the modelwithSession(engine) assession:
session.add(Hero(name="short"))
session.commit()
# Write invalid data directly to the database, bypassing the modelwithengine.connect() asconn:
conn.execute(
Hero.__table__.update()
.where(Hero.__table__.c.id==1)
.values(name="this is way too long")
)
conn.commit()
# Load from database — no validation runs, invalid data loads finewithSession(engine) assession:
loaded=session.exec(select(Hero)).first()
print(f"Loaded from DB: {loaded.name!r}")
Loaded from DB: 'this is way too long'

Breaking Change

This could represent a behavior change for code that previously constructed table=True models with invalid data.

Related Issues and PRs

  • #52 — SQLModel doesn't raise ValidationError
  • #453 — Why does a SQLModel class with table=True not validate data?
  • #134 — Pydantic Validators does not raise ValueError if conditions are not met
  • #1041 — Ensure that type checks are executed when setting table=True
  • #227 — Class Initialisation Validation Kwarg

Thank you for reviewing!

Enable Pydantic validation on table=True model __init__, matching the
existing behavior of model_validate(). Validation does not run on ORM
loads from the database — SQLAlchemy does not call __init__ when
hydrating from query results.

@mahdirajaeemahdirajaee left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a significant behavioral change — table models now run Pydantic validation on __init__ instead of bypassing it via sqlmodel_table_construct. The key insight is that validate_python(data, self_instance=self) is called unconditionally for both table and non-table models now, which means field validators, model validators, and type coercion all fire during construction. The test suite additions are excellent and cover the critical scenarios: field_validator, model_validator (both before/after modes), BeforeValidator via Annotated, and crucially test_validation_does_not_run_on_orm_load which verifies that loading from the database still bypasses validation (important for not breaking existing data that might not pass newer validators).

The removal of sqlmodel_table_construct is the right call since it was essentially a copy of Pydantic's model_construct() that skipped validation — which was the original bug. One thing to watch: this is a breaking change for users who relied on being able to instantiate table models with missing required fields (the test_not_allow_instantiation_without_arguments test makes this explicit). The old test test_allow_instantiation_without_arguments previously passed with Item() where name: str had no default — that will now raise ValidationError. Projects doing item = Item(); item.name = "..." will need to migrate to Item(name="...").

The sqlmodel_init branching for table vs non-table models post-validation is sensible: non-table models merge dicts directly, while table models use setattr to trigger SQLAlchemy instrumentation and manually restore __pydantic_fields_set__. Worth confirming that the fields_set save/restore around the setattr loop doesn't cause issues if a setattr triggers a SQLAlchemy event that modifies __pydantic_fields_set__ internally, though that seems unlikely in practice.

@svlandeg

This comment was marked as off-topic.

@parthmishra

This comment was marked as off-topic.

@Graeme22

Graeme22 commented May 5, 2026

Copy link
Copy Markdown

This is great work and desperately needed! I hope @tiangolo will consider these changes. Might have to bump the middle version number, though the breakage this will cause will mostly be "good" in that it will catch incorrect code that was silently passing before.

In my projects I have a lot of code like:

classHeroValidate(SQLModel):
age: intclassHero(HeroValidate, table=True):
id: int=Field(...)
hero=HeroValidate(42)
hero_db=Hero(**hero.model_dump())
session.add(hero_db)

This is of course unwieldy, and it's quite clear many users have been bitten by this behavior at one point or another, so it should be a high priority to fix.

@clstaudt

Copy link
Copy Markdown

this breaks the contract that "a SQLModel model is also a Pydantic model".

Exactly. I was very surprised to realise that this is not actually the case. I hope this can be corrected soon.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@ClayGendron@svlandeg@parthmishra@Graeme22@clstaudt@mahdirajaee@tiangolo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Validate SQLModel on all Non-Database Sourced Data - #1823

Open
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction
Open

Validate SQLModel on all Non-Database Sourced Data#1823
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction

Conversation

@ClayGendron

Copy link
Copy Markdown

As noted by other issues and pull requests, when setting table=True in a SQLModel, Pydantic validation does not run, and this breaks the contract that "a SQLModel model is also a Pydantic model". This PR builds on prior ones and also hopes to address concerns with changing the intentional validation bypass for table models.

First, for those new to this issue, here is an example of how SQLModels behave differently when they are a table:

frompydanticimportBaseModel, ValidationErrorfromsqlmodelimportSQLModel, FieldclassHeroBase(BaseModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLBase(SQLModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLTable(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strage: inttry:
HeroBase(name="Deadpond", age="not an int")
print("HeroBase: created with invalid data!")
exceptValidationError:
print("HeroBase: ValidationError raised")
try:
HeroSQLBase(name="Deadpond", age="not an int")
print("HeroSQLBase: created with invalid data!")
exceptValidationError:
print("HeroSQLBase: ValidationError raised")
try:
HeroSQLTable(name="Deadpond", age="not an int")
print("HeroSQLTable: created with invalid data!")
exceptValidationError:
print("HeroSQLTable: ValidationError raised")
HeroBase: ValidationError raised
HeroSQLBase: ValidationError raised
HeroSQLTable: created with invalid data!

The same is true for @field_validator and @model_validator. Both will be silently ignored when table=True.

Use Case

I am building a library that includes a base SQLModel class (without table=True) that has validators to normalize and populate data. Downstream developers using my library would then create their own table=True class that inherits from the base:

# ---- library code ----importhashlibimportposixpathimportuuidfrompydanticimportfield_validator, model_validatorfromsqlmodelimportSQLModel, FieldclassDocumentBase(SQLModel):
id: str=Field(
default_factory=lambda: str(uuid.uuid4()), max_length=256, primary_key=True
)
path: strcontent: strcontent_hash: str=""@field_validator("path")@classmethoddefnormalize_path(cls, v: str) ->str:
ifnotv.startswith("/"):
v="/"+vreturnposixpath.normpath(v)
@model_validator(mode="after")defcompute_content_hash(self) ->"DocumentBase":
self.content_hash=hashlib.sha256(self.content.encode()).hexdigest()
returnself# ---- downstream developer code ----classDocument(DocumentBase, table=True):
project_field: str|None=None

The base class works correctly on its own, but it can't hold the downstream project_field:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
DocumentBase(
path='/path', # normalizedcontent='hello',
content_hash='2cf24db...', # computed# project_field missing!
)

But when a downstream developer inherits with table=True, both validators are silently skipped:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
Document(
path='not_normalized/../path', # not normalized!content='hello',
content_hash='', # not computed!project_field='important info!',
)

My library must rely on validation from the custom defined inherited class, but it will not work out of the box. My issue could be resolved with the solution described in the multiple models doc, but that approach would mean I would be asking developers to create twice as many classes, one for validation and one for table mapping. Using SQLModel was chosen for this project as it promised to provide a unified model between Pydantic and SQLAlchemy, which in my case, means any initialized model derived from DocumentBase is valid, both in python and in the database.

The Change

The change is in sqlmodel_init() in _compat.py. Previously, table models called sqlmodel_table_construct() which skips validation entirely. Now, all models go through validate_python(), and table models do a post-validation step to re-trigger SQLAlchemy instrumentation via setattr:

defsqlmodel_init(*, self: "SQLModel", data: dict[str, Any]) ->None:
old_dict=self.__dict__.copy()
self.__pydantic_validator__.validate_python(
data,
self_instance=self,
)
ifnotis_table_model_class(self.__class__):
object.__setattr__(
self,
"__dict__",
{**old_dict, **self.__dict__},
)
else:
fields_set=self.__pydantic_fields_set__.copy()
forkey, valuein {**old_dict, **self.__dict__}.items():
setattr(self, key, value)
object.__setattr__(self, "__pydantic_fields_set__", fields_set)
forkeyinself.__sqlmodel_relationships__:
value=data.get(key, Undefined)
ifvalueisnotUndefined:
setattr(self, key, value)

This mirrors the existing pattern used by sqlmodel_validate() (the model_validate() path) which already validates table models successfully.

Addressing Prior Concerns

"SQLAlchemy needs to assign values after instantiation" (#52)

The concern raised in #52 was that relationships need to be assignable after construction, so validation can't run on __init__.

Relationships are not part of model_fields — they live in __sqlmodel_relationships__ and are handled separately, outside of Pydantic validation. validate_python() never sees or validates relationship attributes. Both sides of a bidirectional relationship can be created independently, exactly as before:

fromsqlmodelimportSQLModel, Field, Relationship, Session, create_engineclassTeam(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strheroes: list["Hero"] =Relationship(back_populates="team")
classHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strteam_id: int|None=Field(default=None, foreign_key="team.id")
team: Team|None=Relationship(back_populates="heroes")
# Create each side independently — no relationship passedteam=Team(name="Preventers")
hero=Hero(name="Deadpond")
# Assign relationship after constructionhero.team=teamengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
withSession(engine) assession:
session.add(hero)
session.commit()
session.refresh(hero)
print(f"{hero.name}'s team: {hero.team.name}")
Deadpond's team: Preventers

Performance on ORM Reads

Validation does not run when loading from the database. SQLAlchemy does not call __init__ when hydrating instances from query results (SQLAlchemy docs: Constructors and Object Initialization). This is unchanged, as it is safe to assume that data loaded from the database is valid.

To verify, here is a test that writes invalid data directly to the database and confirms it loads without triggering validation:

frompydanticimportfield_validatorfromsqlmodelimportSQLModel, Field, Session, create_engine, selectclassHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: str@field_validator("name")@classmethoddefname_must_be_short(cls, v: str) ->str:
iflen(v) >5:
raiseValueError("too long")
returnvengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
# Insert valid data through the modelwithSession(engine) assession:
session.add(Hero(name="short"))
session.commit()
# Write invalid data directly to the database, bypassing the modelwithengine.connect() asconn:
conn.execute(
Hero.__table__.update()
.where(Hero.__table__.c.id==1)
.values(name="this is way too long")
)
conn.commit()
# Load from database — no validation runs, invalid data loads finewithSession(engine) assession:
loaded=session.exec(select(Hero)).first()
print(f"Loaded from DB: {loaded.name!r}")
Loaded from DB: 'this is way too long'

Breaking Change

This could represent a behavior change for code that previously constructed table=True models with invalid data.

Related Issues and PRs

  • #52 — SQLModel doesn't raise ValidationError
  • #453 — Why does a SQLModel class with table=True not validate data?
  • #134 — Pydantic Validators does not raise ValueError if conditions are not met
  • #1041 — Ensure that type checks are executed when setting table=True
  • #227 — Class Initialisation Validation Kwarg

Thank you for reviewing!

Enable Pydantic validation on table=True model __init__, matching the
existing behavior of model_validate(). Validation does not run on ORM
loads from the database — SQLAlchemy does not call __init__ when
hydrating from query results.

@mahdirajaeemahdirajaee left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a significant behavioral change — table models now run Pydantic validation on __init__ instead of bypassing it via sqlmodel_table_construct. The key insight is that validate_python(data, self_instance=self) is called unconditionally for both table and non-table models now, which means field validators, model validators, and type coercion all fire during construction. The test suite additions are excellent and cover the critical scenarios: field_validator, model_validator (both before/after modes), BeforeValidator via Annotated, and crucially test_validation_does_not_run_on_orm_load which verifies that loading from the database still bypasses validation (important for not breaking existing data that might not pass newer validators).

The removal of sqlmodel_table_construct is the right call since it was essentially a copy of Pydantic's model_construct() that skipped validation — which was the original bug. One thing to watch: this is a breaking change for users who relied on being able to instantiate table models with missing required fields (the test_not_allow_instantiation_without_arguments test makes this explicit). The old test test_allow_instantiation_without_arguments previously passed with Item() where name: str had no default — that will now raise ValidationError. Projects doing item = Item(); item.name = "..." will need to migrate to Item(name="...").

The sqlmodel_init branching for table vs non-table models post-validation is sensible: non-table models merge dicts directly, while table models use setattr to trigger SQLAlchemy instrumentation and manually restore __pydantic_fields_set__. Worth confirming that the fields_set save/restore around the setattr loop doesn't cause issues if a setattr triggers a SQLAlchemy event that modifies __pydantic_fields_set__ internally, though that seems unlikely in practice.

@svlandeg

This comment was marked as off-topic.

@parthmishra

This comment was marked as off-topic.

@Graeme22

Graeme22 commented May 5, 2026

Copy link
Copy Markdown

This is great work and desperately needed! I hope @tiangolo will consider these changes. Might have to bump the middle version number, though the breakage this will cause will mostly be "good" in that it will catch incorrect code that was silently passing before.

In my projects I have a lot of code like:

classHeroValidate(SQLModel):
age: intclassHero(HeroValidate, table=True):
id: int=Field(...)
hero=HeroValidate(42)
hero_db=Hero(**hero.model_dump())
session.add(hero_db)

This is of course unwieldy, and it's quite clear many users have been bitten by this behavior at one point or another, so it should be a high priority to fix.

@clstaudt

Copy link
Copy Markdown

this breaks the contract that "a SQLModel model is also a Pydantic model".

Exactly. I was very surprised to realise that this is not actually the case. I hope this can be corrected soon.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@ClayGendron@svlandeg@parthmishra@Graeme22@clstaudt@mahdirajaee@tiangolo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Validate SQLModel on all Non-Database Sourced Data - #1823

Open
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction
Open

Validate SQLModel on all Non-Database Sourced Data#1823
ClayGendron wants to merge 4 commits into
fastapi:mainfrom
ClayGendron:validate-table-models-on-construction

Conversation

@ClayGendron

Copy link
Copy Markdown

As noted by other issues and pull requests, when setting table=True in a SQLModel, Pydantic validation does not run, and this breaks the contract that "a SQLModel model is also a Pydantic model". This PR builds on prior ones and also hopes to address concerns with changing the intentional validation bypass for table models.

First, for those new to this issue, here is an example of how SQLModels behave differently when they are a table:

frompydanticimportBaseModel, ValidationErrorfromsqlmodelimportSQLModel, FieldclassHeroBase(BaseModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLBase(SQLModel):
id: int|None=Field(default=None, primary_key=True)
name: strage: intclassHeroSQLTable(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strage: inttry:
HeroBase(name="Deadpond", age="not an int")
print("HeroBase: created with invalid data!")
exceptValidationError:
print("HeroBase: ValidationError raised")
try:
HeroSQLBase(name="Deadpond", age="not an int")
print("HeroSQLBase: created with invalid data!")
exceptValidationError:
print("HeroSQLBase: ValidationError raised")
try:
HeroSQLTable(name="Deadpond", age="not an int")
print("HeroSQLTable: created with invalid data!")
exceptValidationError:
print("HeroSQLTable: ValidationError raised")
HeroBase: ValidationError raised
HeroSQLBase: ValidationError raised
HeroSQLTable: created with invalid data!

The same is true for @field_validator and @model_validator. Both will be silently ignored when table=True.

Use Case

I am building a library that includes a base SQLModel class (without table=True) that has validators to normalize and populate data. Downstream developers using my library would then create their own table=True class that inherits from the base:

# ---- library code ----importhashlibimportposixpathimportuuidfrompydanticimportfield_validator, model_validatorfromsqlmodelimportSQLModel, FieldclassDocumentBase(SQLModel):
id: str=Field(
default_factory=lambda: str(uuid.uuid4()), max_length=256, primary_key=True
)
path: strcontent: strcontent_hash: str=""@field_validator("path")@classmethoddefnormalize_path(cls, v: str) ->str:
ifnotv.startswith("/"):
v="/"+vreturnposixpath.normpath(v)
@model_validator(mode="after")defcompute_content_hash(self) ->"DocumentBase":
self.content_hash=hashlib.sha256(self.content.encode()).hexdigest()
returnself# ---- downstream developer code ----classDocument(DocumentBase, table=True):
project_field: str|None=None

The base class works correctly on its own, but it can't hold the downstream project_field:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
DocumentBase(
path='/path', # normalizedcontent='hello',
content_hash='2cf24db...', # computed# project_field missing!
)

But when a downstream developer inherits with table=True, both validators are silently skipped:

>>>Document(path="not_normalized/../path", content="hello", project_field="important info!")
Document(
path='not_normalized/../path', # not normalized!content='hello',
content_hash='', # not computed!project_field='important info!',
)

My library must rely on validation from the custom defined inherited class, but it will not work out of the box. My issue could be resolved with the solution described in the multiple models doc, but that approach would mean I would be asking developers to create twice as many classes, one for validation and one for table mapping. Using SQLModel was chosen for this project as it promised to provide a unified model between Pydantic and SQLAlchemy, which in my case, means any initialized model derived from DocumentBase is valid, both in python and in the database.

The Change

The change is in sqlmodel_init() in _compat.py. Previously, table models called sqlmodel_table_construct() which skips validation entirely. Now, all models go through validate_python(), and table models do a post-validation step to re-trigger SQLAlchemy instrumentation via setattr:

defsqlmodel_init(*, self: "SQLModel", data: dict[str, Any]) ->None:
old_dict=self.__dict__.copy()
self.__pydantic_validator__.validate_python(
data,
self_instance=self,
)
ifnotis_table_model_class(self.__class__):
object.__setattr__(
self,
"__dict__",
{**old_dict, **self.__dict__},
)
else:
fields_set=self.__pydantic_fields_set__.copy()
forkey, valuein {**old_dict, **self.__dict__}.items():
setattr(self, key, value)
object.__setattr__(self, "__pydantic_fields_set__", fields_set)
forkeyinself.__sqlmodel_relationships__:
value=data.get(key, Undefined)
ifvalueisnotUndefined:
setattr(self, key, value)

This mirrors the existing pattern used by sqlmodel_validate() (the model_validate() path) which already validates table models successfully.

Addressing Prior Concerns

"SQLAlchemy needs to assign values after instantiation" (#52)

The concern raised in #52 was that relationships need to be assignable after construction, so validation can't run on __init__.

Relationships are not part of model_fields — they live in __sqlmodel_relationships__ and are handled separately, outside of Pydantic validation. validate_python() never sees or validates relationship attributes. Both sides of a bidirectional relationship can be created independently, exactly as before:

fromsqlmodelimportSQLModel, Field, Relationship, Session, create_engineclassTeam(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strheroes: list["Hero"] =Relationship(back_populates="team")
classHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: strteam_id: int|None=Field(default=None, foreign_key="team.id")
team: Team|None=Relationship(back_populates="heroes")
# Create each side independently — no relationship passedteam=Team(name="Preventers")
hero=Hero(name="Deadpond")
# Assign relationship after constructionhero.team=teamengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
withSession(engine) assession:
session.add(hero)
session.commit()
session.refresh(hero)
print(f"{hero.name}'s team: {hero.team.name}")
Deadpond's team: Preventers

Performance on ORM Reads

Validation does not run when loading from the database. SQLAlchemy does not call __init__ when hydrating instances from query results (SQLAlchemy docs: Constructors and Object Initialization). This is unchanged, as it is safe to assume that data loaded from the database is valid.

To verify, here is a test that writes invalid data directly to the database and confirms it loads without triggering validation:

frompydanticimportfield_validatorfromsqlmodelimportSQLModel, Field, Session, create_engine, selectclassHero(SQLModel, table=True):
id: int|None=Field(default=None, primary_key=True)
name: str@field_validator("name")@classmethoddefname_must_be_short(cls, v: str) ->str:
iflen(v) >5:
raiseValueError("too long")
returnvengine=create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
# Insert valid data through the modelwithSession(engine) assession:
session.add(Hero(name="short"))
session.commit()
# Write invalid data directly to the database, bypassing the modelwithengine.connect() asconn:
conn.execute(
Hero.__table__.update()
.where(Hero.__table__.c.id==1)
.values(name="this is way too long")
)
conn.commit()
# Load from database — no validation runs, invalid data loads finewithSession(engine) assession:
loaded=session.exec(select(Hero)).first()
print(f"Loaded from DB: {loaded.name!r}")
Loaded from DB: 'this is way too long'

Breaking Change

This could represent a behavior change for code that previously constructed table=True models with invalid data.

Related Issues and PRs

  • #52 — SQLModel doesn't raise ValidationError
  • #453 — Why does a SQLModel class with table=True not validate data?
  • #134 — Pydantic Validators does not raise ValueError if conditions are not met
  • #1041 — Ensure that type checks are executed when setting table=True
  • #227 — Class Initialisation Validation Kwarg

Thank you for reviewing!

Enable Pydantic validation on table=True model __init__, matching the
existing behavior of model_validate(). Validation does not run on ORM
loads from the database — SQLAlchemy does not call __init__ when
hydrating from query results.

@mahdirajaeemahdirajaee left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a significant behavioral change — table models now run Pydantic validation on __init__ instead of bypassing it via sqlmodel_table_construct. The key insight is that validate_python(data, self_instance=self) is called unconditionally for both table and non-table models now, which means field validators, model validators, and type coercion all fire during construction. The test suite additions are excellent and cover the critical scenarios: field_validator, model_validator (both before/after modes), BeforeValidator via Annotated, and crucially test_validation_does_not_run_on_orm_load which verifies that loading from the database still bypasses validation (important for not breaking existing data that might not pass newer validators).

The removal of sqlmodel_table_construct is the right call since it was essentially a copy of Pydantic's model_construct() that skipped validation — which was the original bug. One thing to watch: this is a breaking change for users who relied on being able to instantiate table models with missing required fields (the test_not_allow_instantiation_without_arguments test makes this explicit). The old test test_allow_instantiation_without_arguments previously passed with Item() where name: str had no default — that will now raise ValidationError. Projects doing item = Item(); item.name = "..." will need to migrate to Item(name="...").

The sqlmodel_init branching for table vs non-table models post-validation is sensible: non-table models merge dicts directly, while table models use setattr to trigger SQLAlchemy instrumentation and manually restore __pydantic_fields_set__. Worth confirming that the fields_set save/restore around the setattr loop doesn't cause issues if a setattr triggers a SQLAlchemy event that modifies __pydantic_fields_set__ internally, though that seems unlikely in practice.

@svlandeg

This comment was marked as off-topic.

@parthmishra

This comment was marked as off-topic.

@Graeme22

Graeme22 commented May 5, 2026

Copy link
Copy Markdown

This is great work and desperately needed! I hope @tiangolo will consider these changes. Might have to bump the middle version number, though the breakage this will cause will mostly be "good" in that it will catch incorrect code that was silently passing before.

In my projects I have a lot of code like:

classHeroValidate(SQLModel):
age: intclassHero(HeroValidate, table=True):
id: int=Field(...)
hero=HeroValidate(42)
hero_db=Hero(**hero.model_dump())
session.add(hero_db)

This is of course unwieldy, and it's quite clear many users have been bitten by this behavior at one point or another, so it should be a high priority to fix.

@clstaudt

Copy link
Copy Markdown

this breaks the contract that "a SQLModel model is also a Pydantic model".

Exactly. I was very surprised to realise that this is not actually the case. I hope this can be corrected soon.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@ClayGendron@svlandeg@parthmishra@Graeme22@clstaudt@mahdirajaee@tiangolo