🐛 Declare __tablename__ on the metaclass (improve pyright compatibility) - #1821

Open
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property
Open

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)#1821
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property

Conversation

@estarfoo

@estarfooestarfoo commented Mar 18, 2026

Copy link
Copy Markdown

Move __tablename__ from a @declared_attr method on SQLModel to a declaration on SQLModelMetaclass, with the default set in SQLModelMetaclass.__new__ before class creation (unless the user supplied one).

This resolves the type-level contradiction between the ClassVar[str | Callable] declaration and the @declared_attr descriptor, letting __tablename__ = "my_table" work without # type: ignore for pyright.

Caveat: dynamically computed names via @declared_attr.directive (per @YuriiMotov's review below) type-check in pyright basic mode. In standard/strict mode, one reportIncompatibleVariableOverride diagnostic remains.

Tests added for default name, explicit override, inheritance, non-table models, and the @declared_attr.directive form.

Fixes#98.

This is split out from #1820, dropping those changes which would be resolved by #1345 or #1806.

(Description edited following source branch update from 24d66f7 to 415c8fc.)

@svlandegsvlandeg added the bug Something isn't working label Mar 18, 2026
@YuriiMotov

Copy link
Copy Markdown
Member

Spent a little time on reviewing this PR and haven't found any issues so far. All seems to work well.

Would be also good to fix the case with dynamically created table names as well:

from pydantic.alias_generators import to_snake
from sqlalchemy.orm import declared_attr
from sqlmodel import Field, SQLModel
class FirstWidget(SQLModel):
id: int | None = Field(default=None, primary_key=True)
name: str
@declared_attr.directive
@classmethod
def __tablename__(cls) -> str:
return to_snake(cls.__name__)
assert FirstWidget.__tablename__ == "first_widget"

Works well, but pyright argues:

image

@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, thank you very much for the review! Sorry for being slow to get back to this.

I agree that the dynamic table name is a valid use case and deserves support along with the static case. However:

  • SQLAlchemy doesn't seem to export the type for @declared_attr.directive. To match it exactly, I'd have to import the private name, which I assume nobody would want:
fromsqlalchemy.orm.decl_apiimport_declared_directive# …__tablename__: ClassVar[str|Callable[..., str] |_declared_directive[str]]
  • __tablename__ is Any in SQLAlchemy's DeclarativeBase anyway. I can do this:
__tablename__: ClassVar[Any]

But the second option loses typing for table names. Any preference, or do you maybe see another way?

The `SQLModel` base declared `__tablename__` both as
`ClassVar[str | Callable[..., str]]` and as a `@declared_attr` method.
Type checkers (pyright) see the descriptor type from `@declared_attr`, so
`__tablename__ = "my_table"` in a subclass is rejected as a type mismatch
even though it works at runtime; and the `ClassVar` on the base both leaks
into the constructor signature and conflicts with a descriptor override
such as `@declared_attr.directive`.
Move the declaration to `SQLModelMetaclass` as `__tablename__: str`, setting
the default in `SQLModelMetaclass.__new__` (in the class dict, before class
creation) unless the user supplied one. Because the attribute now lives on
the metaclass:
- explicit names (`__tablename__ = "my_table"`) narrow to `str` rather than
the `str | Callable` union, so reads are usable without casts;
- it is not collected as a model field, so it never appears in the
constructor;
- a dynamically computed name via `@declared_attr.directive` type-checks in
pyright basic mode, leaving only a single reportIncompatibleVariableOverride
in standard/strict mode, inherent to overriding a class attribute with a
descriptor.
Tests added for default name, explicit override, inheritance, non-table
models, and the `@declared_attr.directive` form.
Fixesfastapi#98.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@estarfoo
estarfooforce-pushed the feat/tablename-property branch from 24d66f7 to 415c8fcCompareJune 10, 2026 09:38
@estarfooestarfoo changed the title 🐛 Move __tablename__ default from @declared_attr to metaclass🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)Jun 10, 2026
@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, following up on dynamic table names: neither option from above turned out to be satisfying, so here's a rework shifting the __tablename__ declaration itself, not just its default value.

The current version would still produce a pyright message. I don't see a way around this at the moment, and I think it's an intrinsic limitation coming from SQLAlchemy. I'm open to suggestions on this, though!

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

Labels

bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pyright cannot recognize the type of SQLModel.__tablename__

4 participants

@estarfoo@YuriiMotov@tiangolo@svlandeg
, '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

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility) - #1821

Open
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property
Open

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)#1821
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property

Conversation

@estarfoo

@estarfooestarfoo commented Mar 18, 2026

Copy link
Copy Markdown

Move __tablename__ from a @declared_attr method on SQLModel to a declaration on SQLModelMetaclass, with the default set in SQLModelMetaclass.__new__ before class creation (unless the user supplied one).

This resolves the type-level contradiction between the ClassVar[str | Callable] declaration and the @declared_attr descriptor, letting __tablename__ = "my_table" work without # type: ignore for pyright.

Caveat: dynamically computed names via @declared_attr.directive (per @YuriiMotov's review below) type-check in pyright basic mode. In standard/strict mode, one reportIncompatibleVariableOverride diagnostic remains.

Tests added for default name, explicit override, inheritance, non-table models, and the @declared_attr.directive form.

Fixes#98.

This is split out from #1820, dropping those changes which would be resolved by #1345 or #1806.

(Description edited following source branch update from 24d66f7 to 415c8fc.)

@svlandegsvlandeg added the bug Something isn't working label Mar 18, 2026
@YuriiMotov

Copy link
Copy Markdown
Member

Spent a little time on reviewing this PR and haven't found any issues so far. All seems to work well.

Would be also good to fix the case with dynamically created table names as well:

from pydantic.alias_generators import to_snake
from sqlalchemy.orm import declared_attr
from sqlmodel import Field, SQLModel
class FirstWidget(SQLModel):
id: int | None = Field(default=None, primary_key=True)
name: str
@declared_attr.directive
@classmethod
def __tablename__(cls) -> str:
return to_snake(cls.__name__)
assert FirstWidget.__tablename__ == "first_widget"

Works well, but pyright argues:

image

@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, thank you very much for the review! Sorry for being slow to get back to this.

I agree that the dynamic table name is a valid use case and deserves support along with the static case. However:

  • SQLAlchemy doesn't seem to export the type for @declared_attr.directive. To match it exactly, I'd have to import the private name, which I assume nobody would want:
fromsqlalchemy.orm.decl_apiimport_declared_directive# …__tablename__: ClassVar[str|Callable[..., str] |_declared_directive[str]]
  • __tablename__ is Any in SQLAlchemy's DeclarativeBase anyway. I can do this:
__tablename__: ClassVar[Any]

But the second option loses typing for table names. Any preference, or do you maybe see another way?

The `SQLModel` base declared `__tablename__` both as
`ClassVar[str | Callable[..., str]]` and as a `@declared_attr` method.
Type checkers (pyright) see the descriptor type from `@declared_attr`, so
`__tablename__ = "my_table"` in a subclass is rejected as a type mismatch
even though it works at runtime; and the `ClassVar` on the base both leaks
into the constructor signature and conflicts with a descriptor override
such as `@declared_attr.directive`.
Move the declaration to `SQLModelMetaclass` as `__tablename__: str`, setting
the default in `SQLModelMetaclass.__new__` (in the class dict, before class
creation) unless the user supplied one. Because the attribute now lives on
the metaclass:
- explicit names (`__tablename__ = "my_table"`) narrow to `str` rather than
the `str | Callable` union, so reads are usable without casts;
- it is not collected as a model field, so it never appears in the
constructor;
- a dynamically computed name via `@declared_attr.directive` type-checks in
pyright basic mode, leaving only a single reportIncompatibleVariableOverride
in standard/strict mode, inherent to overriding a class attribute with a
descriptor.
Tests added for default name, explicit override, inheritance, non-table
models, and the `@declared_attr.directive` form.
Fixesfastapi#98.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@estarfoo
estarfooforce-pushed the feat/tablename-property branch from 24d66f7 to 415c8fcCompareJune 10, 2026 09:38
@estarfooestarfoo changed the title 🐛 Move __tablename__ default from @declared_attr to metaclass🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)Jun 10, 2026
@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, following up on dynamic table names: neither option from above turned out to be satisfying, so here's a rework shifting the __tablename__ declaration itself, not just its default value.

The current version would still produce a pyright message. I don't see a way around this at the moment, and I think it's an intrinsic limitation coming from SQLAlchemy. I'm open to suggestions on this, though!

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

Labels

bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pyright cannot recognize the type of SQLModel.__tablename__

4 participants

@estarfoo@YuriiMotov@tiangolo@svlandeg
, '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

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility) - #1821

Open
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property
Open

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)#1821
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property

Conversation

@estarfoo

@estarfooestarfoo commented Mar 18, 2026

Copy link
Copy Markdown

Move __tablename__ from a @declared_attr method on SQLModel to a declaration on SQLModelMetaclass, with the default set in SQLModelMetaclass.__new__ before class creation (unless the user supplied one).

This resolves the type-level contradiction between the ClassVar[str | Callable] declaration and the @declared_attr descriptor, letting __tablename__ = "my_table" work without # type: ignore for pyright.

Caveat: dynamically computed names via @declared_attr.directive (per @YuriiMotov's review below) type-check in pyright basic mode. In standard/strict mode, one reportIncompatibleVariableOverride diagnostic remains.

Tests added for default name, explicit override, inheritance, non-table models, and the @declared_attr.directive form.

Fixes#98.

This is split out from #1820, dropping those changes which would be resolved by #1345 or #1806.

(Description edited following source branch update from 24d66f7 to 415c8fc.)

@svlandegsvlandeg added the bug Something isn't working label Mar 18, 2026
@YuriiMotov

Copy link
Copy Markdown
Member

Spent a little time on reviewing this PR and haven't found any issues so far. All seems to work well.

Would be also good to fix the case with dynamically created table names as well:

from pydantic.alias_generators import to_snake
from sqlalchemy.orm import declared_attr
from sqlmodel import Field, SQLModel
class FirstWidget(SQLModel):
id: int | None = Field(default=None, primary_key=True)
name: str
@declared_attr.directive
@classmethod
def __tablename__(cls) -> str:
return to_snake(cls.__name__)
assert FirstWidget.__tablename__ == "first_widget"

Works well, but pyright argues:

image

@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, thank you very much for the review! Sorry for being slow to get back to this.

I agree that the dynamic table name is a valid use case and deserves support along with the static case. However:

  • SQLAlchemy doesn't seem to export the type for @declared_attr.directive. To match it exactly, I'd have to import the private name, which I assume nobody would want:
fromsqlalchemy.orm.decl_apiimport_declared_directive# …__tablename__: ClassVar[str|Callable[..., str] |_declared_directive[str]]
  • __tablename__ is Any in SQLAlchemy's DeclarativeBase anyway. I can do this:
__tablename__: ClassVar[Any]

But the second option loses typing for table names. Any preference, or do you maybe see another way?

The `SQLModel` base declared `__tablename__` both as
`ClassVar[str | Callable[..., str]]` and as a `@declared_attr` method.
Type checkers (pyright) see the descriptor type from `@declared_attr`, so
`__tablename__ = "my_table"` in a subclass is rejected as a type mismatch
even though it works at runtime; and the `ClassVar` on the base both leaks
into the constructor signature and conflicts with a descriptor override
such as `@declared_attr.directive`.
Move the declaration to `SQLModelMetaclass` as `__tablename__: str`, setting
the default in `SQLModelMetaclass.__new__` (in the class dict, before class
creation) unless the user supplied one. Because the attribute now lives on
the metaclass:
- explicit names (`__tablename__ = "my_table"`) narrow to `str` rather than
the `str | Callable` union, so reads are usable without casts;
- it is not collected as a model field, so it never appears in the
constructor;
- a dynamically computed name via `@declared_attr.directive` type-checks in
pyright basic mode, leaving only a single reportIncompatibleVariableOverride
in standard/strict mode, inherent to overriding a class attribute with a
descriptor.
Tests added for default name, explicit override, inheritance, non-table
models, and the `@declared_attr.directive` form.
Fixesfastapi#98.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@estarfoo
estarfooforce-pushed the feat/tablename-property branch from 24d66f7 to 415c8fcCompareJune 10, 2026 09:38
@estarfooestarfoo changed the title 🐛 Move __tablename__ default from @declared_attr to metaclass🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)Jun 10, 2026
@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, following up on dynamic table names: neither option from above turned out to be satisfying, so here's a rework shifting the __tablename__ declaration itself, not just its default value.

The current version would still produce a pyright message. I don't see a way around this at the moment, and I think it's an intrinsic limitation coming from SQLAlchemy. I'm open to suggestions on this, though!

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

Labels

bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pyright cannot recognize the type of SQLModel.__tablename__

4 participants

@estarfoo@YuriiMotov@tiangolo@svlandeg
, '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

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility) - #1821

Open
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property
Open

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)#1821
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property

Conversation

@estarfoo

@estarfooestarfoo commented Mar 18, 2026

Copy link
Copy Markdown

Move __tablename__ from a @declared_attr method on SQLModel to a declaration on SQLModelMetaclass, with the default set in SQLModelMetaclass.__new__ before class creation (unless the user supplied one).

This resolves the type-level contradiction between the ClassVar[str | Callable] declaration and the @declared_attr descriptor, letting __tablename__ = "my_table" work without # type: ignore for pyright.

Caveat: dynamically computed names via @declared_attr.directive (per @YuriiMotov's review below) type-check in pyright basic mode. In standard/strict mode, one reportIncompatibleVariableOverride diagnostic remains.

Tests added for default name, explicit override, inheritance, non-table models, and the @declared_attr.directive form.

Fixes#98.

This is split out from #1820, dropping those changes which would be resolved by #1345 or #1806.

(Description edited following source branch update from 24d66f7 to 415c8fc.)

@svlandegsvlandeg added the bug Something isn't working label Mar 18, 2026
@YuriiMotov

Copy link
Copy Markdown
Member

Spent a little time on reviewing this PR and haven't found any issues so far. All seems to work well.

Would be also good to fix the case with dynamically created table names as well:

from pydantic.alias_generators import to_snake
from sqlalchemy.orm import declared_attr
from sqlmodel import Field, SQLModel
class FirstWidget(SQLModel):
id: int | None = Field(default=None, primary_key=True)
name: str
@declared_attr.directive
@classmethod
def __tablename__(cls) -> str:
return to_snake(cls.__name__)
assert FirstWidget.__tablename__ == "first_widget"

Works well, but pyright argues:

image

@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, thank you very much for the review! Sorry for being slow to get back to this.

I agree that the dynamic table name is a valid use case and deserves support along with the static case. However:

  • SQLAlchemy doesn't seem to export the type for @declared_attr.directive. To match it exactly, I'd have to import the private name, which I assume nobody would want:
fromsqlalchemy.orm.decl_apiimport_declared_directive# …__tablename__: ClassVar[str|Callable[..., str] |_declared_directive[str]]
  • __tablename__ is Any in SQLAlchemy's DeclarativeBase anyway. I can do this:
__tablename__: ClassVar[Any]

But the second option loses typing for table names. Any preference, or do you maybe see another way?

The `SQLModel` base declared `__tablename__` both as
`ClassVar[str | Callable[..., str]]` and as a `@declared_attr` method.
Type checkers (pyright) see the descriptor type from `@declared_attr`, so
`__tablename__ = "my_table"` in a subclass is rejected as a type mismatch
even though it works at runtime; and the `ClassVar` on the base both leaks
into the constructor signature and conflicts with a descriptor override
such as `@declared_attr.directive`.
Move the declaration to `SQLModelMetaclass` as `__tablename__: str`, setting
the default in `SQLModelMetaclass.__new__` (in the class dict, before class
creation) unless the user supplied one. Because the attribute now lives on
the metaclass:
- explicit names (`__tablename__ = "my_table"`) narrow to `str` rather than
the `str | Callable` union, so reads are usable without casts;
- it is not collected as a model field, so it never appears in the
constructor;
- a dynamically computed name via `@declared_attr.directive` type-checks in
pyright basic mode, leaving only a single reportIncompatibleVariableOverride
in standard/strict mode, inherent to overriding a class attribute with a
descriptor.
Tests added for default name, explicit override, inheritance, non-table
models, and the `@declared_attr.directive` form.
Fixesfastapi#98.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@estarfoo
estarfooforce-pushed the feat/tablename-property branch from 24d66f7 to 415c8fcCompareJune 10, 2026 09:38
@estarfooestarfoo changed the title 🐛 Move __tablename__ default from @declared_attr to metaclass🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)Jun 10, 2026
@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, following up on dynamic table names: neither option from above turned out to be satisfying, so here's a rework shifting the __tablename__ declaration itself, not just its default value.

The current version would still produce a pyright message. I don't see a way around this at the moment, and I think it's an intrinsic limitation coming from SQLAlchemy. I'm open to suggestions on this, though!

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

Labels

bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pyright cannot recognize the type of SQLModel.__tablename__

4 participants

@estarfoo@YuriiMotov@tiangolo@svlandeg
, '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

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility) - #1821

Open
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property
Open

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)#1821
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property

Conversation

@estarfoo

@estarfooestarfoo commented Mar 18, 2026

Copy link
Copy Markdown

Move __tablename__ from a @declared_attr method on SQLModel to a declaration on SQLModelMetaclass, with the default set in SQLModelMetaclass.__new__ before class creation (unless the user supplied one).

This resolves the type-level contradiction between the ClassVar[str | Callable] declaration and the @declared_attr descriptor, letting __tablename__ = "my_table" work without # type: ignore for pyright.

Caveat: dynamically computed names via @declared_attr.directive (per @YuriiMotov's review below) type-check in pyright basic mode. In standard/strict mode, one reportIncompatibleVariableOverride diagnostic remains.

Tests added for default name, explicit override, inheritance, non-table models, and the @declared_attr.directive form.

Fixes#98.

This is split out from #1820, dropping those changes which would be resolved by #1345 or #1806.

(Description edited following source branch update from 24d66f7 to 415c8fc.)

@svlandegsvlandeg added the bug Something isn't working label Mar 18, 2026
@YuriiMotov

Copy link
Copy Markdown
Member

Spent a little time on reviewing this PR and haven't found any issues so far. All seems to work well.

Would be also good to fix the case with dynamically created table names as well:

from pydantic.alias_generators import to_snake
from sqlalchemy.orm import declared_attr
from sqlmodel import Field, SQLModel
class FirstWidget(SQLModel):
id: int | None = Field(default=None, primary_key=True)
name: str
@declared_attr.directive
@classmethod
def __tablename__(cls) -> str:
return to_snake(cls.__name__)
assert FirstWidget.__tablename__ == "first_widget"

Works well, but pyright argues:

image

@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, thank you very much for the review! Sorry for being slow to get back to this.

I agree that the dynamic table name is a valid use case and deserves support along with the static case. However:

  • SQLAlchemy doesn't seem to export the type for @declared_attr.directive. To match it exactly, I'd have to import the private name, which I assume nobody would want:
fromsqlalchemy.orm.decl_apiimport_declared_directive# …__tablename__: ClassVar[str|Callable[..., str] |_declared_directive[str]]
  • __tablename__ is Any in SQLAlchemy's DeclarativeBase anyway. I can do this:
__tablename__: ClassVar[Any]

But the second option loses typing for table names. Any preference, or do you maybe see another way?

The `SQLModel` base declared `__tablename__` both as
`ClassVar[str | Callable[..., str]]` and as a `@declared_attr` method.
Type checkers (pyright) see the descriptor type from `@declared_attr`, so
`__tablename__ = "my_table"` in a subclass is rejected as a type mismatch
even though it works at runtime; and the `ClassVar` on the base both leaks
into the constructor signature and conflicts with a descriptor override
such as `@declared_attr.directive`.
Move the declaration to `SQLModelMetaclass` as `__tablename__: str`, setting
the default in `SQLModelMetaclass.__new__` (in the class dict, before class
creation) unless the user supplied one. Because the attribute now lives on
the metaclass:
- explicit names (`__tablename__ = "my_table"`) narrow to `str` rather than
the `str | Callable` union, so reads are usable without casts;
- it is not collected as a model field, so it never appears in the
constructor;
- a dynamically computed name via `@declared_attr.directive` type-checks in
pyright basic mode, leaving only a single reportIncompatibleVariableOverride
in standard/strict mode, inherent to overriding a class attribute with a
descriptor.
Tests added for default name, explicit override, inheritance, non-table
models, and the `@declared_attr.directive` form.
Fixesfastapi#98.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@estarfoo
estarfooforce-pushed the feat/tablename-property branch from 24d66f7 to 415c8fcCompareJune 10, 2026 09:38
@estarfooestarfoo changed the title 🐛 Move __tablename__ default from @declared_attr to metaclass🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)Jun 10, 2026
@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, following up on dynamic table names: neither option from above turned out to be satisfying, so here's a rework shifting the __tablename__ declaration itself, not just its default value.

The current version would still produce a pyright message. I don't see a way around this at the moment, and I think it's an intrinsic limitation coming from SQLAlchemy. I'm open to suggestions on this, though!

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

Labels

bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pyright cannot recognize the type of SQLModel.__tablename__

4 participants

@estarfoo@YuriiMotov@tiangolo@svlandeg
, '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

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility) - #1821

Open
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property
Open

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)#1821
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property

Conversation

@estarfoo

@estarfooestarfoo commented Mar 18, 2026

Copy link
Copy Markdown

Move __tablename__ from a @declared_attr method on SQLModel to a declaration on SQLModelMetaclass, with the default set in SQLModelMetaclass.__new__ before class creation (unless the user supplied one).

This resolves the type-level contradiction between the ClassVar[str | Callable] declaration and the @declared_attr descriptor, letting __tablename__ = "my_table" work without # type: ignore for pyright.

Caveat: dynamically computed names via @declared_attr.directive (per @YuriiMotov's review below) type-check in pyright basic mode. In standard/strict mode, one reportIncompatibleVariableOverride diagnostic remains.

Tests added for default name, explicit override, inheritance, non-table models, and the @declared_attr.directive form.

Fixes#98.

This is split out from #1820, dropping those changes which would be resolved by #1345 or #1806.

(Description edited following source branch update from 24d66f7 to 415c8fc.)

@svlandegsvlandeg added the bug Something isn't working label Mar 18, 2026
@YuriiMotov

Copy link
Copy Markdown
Member

Spent a little time on reviewing this PR and haven't found any issues so far. All seems to work well.

Would be also good to fix the case with dynamically created table names as well:

from pydantic.alias_generators import to_snake
from sqlalchemy.orm import declared_attr
from sqlmodel import Field, SQLModel
class FirstWidget(SQLModel):
id: int | None = Field(default=None, primary_key=True)
name: str
@declared_attr.directive
@classmethod
def __tablename__(cls) -> str:
return to_snake(cls.__name__)
assert FirstWidget.__tablename__ == "first_widget"

Works well, but pyright argues:

image

@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, thank you very much for the review! Sorry for being slow to get back to this.

I agree that the dynamic table name is a valid use case and deserves support along with the static case. However:

  • SQLAlchemy doesn't seem to export the type for @declared_attr.directive. To match it exactly, I'd have to import the private name, which I assume nobody would want:
fromsqlalchemy.orm.decl_apiimport_declared_directive# …__tablename__: ClassVar[str|Callable[..., str] |_declared_directive[str]]
  • __tablename__ is Any in SQLAlchemy's DeclarativeBase anyway. I can do this:
__tablename__: ClassVar[Any]

But the second option loses typing for table names. Any preference, or do you maybe see another way?

The `SQLModel` base declared `__tablename__` both as
`ClassVar[str | Callable[..., str]]` and as a `@declared_attr` method.
Type checkers (pyright) see the descriptor type from `@declared_attr`, so
`__tablename__ = "my_table"` in a subclass is rejected as a type mismatch
even though it works at runtime; and the `ClassVar` on the base both leaks
into the constructor signature and conflicts with a descriptor override
such as `@declared_attr.directive`.
Move the declaration to `SQLModelMetaclass` as `__tablename__: str`, setting
the default in `SQLModelMetaclass.__new__` (in the class dict, before class
creation) unless the user supplied one. Because the attribute now lives on
the metaclass:
- explicit names (`__tablename__ = "my_table"`) narrow to `str` rather than
the `str | Callable` union, so reads are usable without casts;
- it is not collected as a model field, so it never appears in the
constructor;
- a dynamically computed name via `@declared_attr.directive` type-checks in
pyright basic mode, leaving only a single reportIncompatibleVariableOverride
in standard/strict mode, inherent to overriding a class attribute with a
descriptor.
Tests added for default name, explicit override, inheritance, non-table
models, and the `@declared_attr.directive` form.
Fixesfastapi#98.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@estarfoo
estarfooforce-pushed the feat/tablename-property branch from 24d66f7 to 415c8fcCompareJune 10, 2026 09:38
@estarfooestarfoo changed the title 🐛 Move __tablename__ default from @declared_attr to metaclass🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)Jun 10, 2026
@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, following up on dynamic table names: neither option from above turned out to be satisfying, so here's a rework shifting the __tablename__ declaration itself, not just its default value.

The current version would still produce a pyright message. I don't see a way around this at the moment, and I think it's an intrinsic limitation coming from SQLAlchemy. I'm open to suggestions on this, though!

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

Labels

bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pyright cannot recognize the type of SQLModel.__tablename__

4 participants

@estarfoo@YuriiMotov@tiangolo@svlandeg
, '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

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility) - #1821

Open
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property
Open

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)#1821
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property

Conversation

@estarfoo

@estarfooestarfoo commented Mar 18, 2026

Copy link
Copy Markdown

Move __tablename__ from a @declared_attr method on SQLModel to a declaration on SQLModelMetaclass, with the default set in SQLModelMetaclass.__new__ before class creation (unless the user supplied one).

This resolves the type-level contradiction between the ClassVar[str | Callable] declaration and the @declared_attr descriptor, letting __tablename__ = "my_table" work without # type: ignore for pyright.

Caveat: dynamically computed names via @declared_attr.directive (per @YuriiMotov's review below) type-check in pyright basic mode. In standard/strict mode, one reportIncompatibleVariableOverride diagnostic remains.

Tests added for default name, explicit override, inheritance, non-table models, and the @declared_attr.directive form.

Fixes#98.

This is split out from #1820, dropping those changes which would be resolved by #1345 or #1806.

(Description edited following source branch update from 24d66f7 to 415c8fc.)

@svlandegsvlandeg added the bug Something isn't working label Mar 18, 2026
@YuriiMotov

Copy link
Copy Markdown
Member

Spent a little time on reviewing this PR and haven't found any issues so far. All seems to work well.

Would be also good to fix the case with dynamically created table names as well:

from pydantic.alias_generators import to_snake
from sqlalchemy.orm import declared_attr
from sqlmodel import Field, SQLModel
class FirstWidget(SQLModel):
id: int | None = Field(default=None, primary_key=True)
name: str
@declared_attr.directive
@classmethod
def __tablename__(cls) -> str:
return to_snake(cls.__name__)
assert FirstWidget.__tablename__ == "first_widget"

Works well, but pyright argues:

image

@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, thank you very much for the review! Sorry for being slow to get back to this.

I agree that the dynamic table name is a valid use case and deserves support along with the static case. However:

  • SQLAlchemy doesn't seem to export the type for @declared_attr.directive. To match it exactly, I'd have to import the private name, which I assume nobody would want:
fromsqlalchemy.orm.decl_apiimport_declared_directive# …__tablename__: ClassVar[str|Callable[..., str] |_declared_directive[str]]
  • __tablename__ is Any in SQLAlchemy's DeclarativeBase anyway. I can do this:
__tablename__: ClassVar[Any]

But the second option loses typing for table names. Any preference, or do you maybe see another way?

The `SQLModel` base declared `__tablename__` both as
`ClassVar[str | Callable[..., str]]` and as a `@declared_attr` method.
Type checkers (pyright) see the descriptor type from `@declared_attr`, so
`__tablename__ = "my_table"` in a subclass is rejected as a type mismatch
even though it works at runtime; and the `ClassVar` on the base both leaks
into the constructor signature and conflicts with a descriptor override
such as `@declared_attr.directive`.
Move the declaration to `SQLModelMetaclass` as `__tablename__: str`, setting
the default in `SQLModelMetaclass.__new__` (in the class dict, before class
creation) unless the user supplied one. Because the attribute now lives on
the metaclass:
- explicit names (`__tablename__ = "my_table"`) narrow to `str` rather than
the `str | Callable` union, so reads are usable without casts;
- it is not collected as a model field, so it never appears in the
constructor;
- a dynamically computed name via `@declared_attr.directive` type-checks in
pyright basic mode, leaving only a single reportIncompatibleVariableOverride
in standard/strict mode, inherent to overriding a class attribute with a
descriptor.
Tests added for default name, explicit override, inheritance, non-table
models, and the `@declared_attr.directive` form.
Fixesfastapi#98.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@estarfoo
estarfooforce-pushed the feat/tablename-property branch from 24d66f7 to 415c8fcCompareJune 10, 2026 09:38
@estarfooestarfoo changed the title 🐛 Move __tablename__ default from @declared_attr to metaclass🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)Jun 10, 2026
@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, following up on dynamic table names: neither option from above turned out to be satisfying, so here's a rework shifting the __tablename__ declaration itself, not just its default value.

The current version would still produce a pyright message. I don't see a way around this at the moment, and I think it's an intrinsic limitation coming from SQLAlchemy. I'm open to suggestions on this, though!

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

Labels

bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pyright cannot recognize the type of SQLModel.__tablename__

4 participants

@estarfoo@YuriiMotov@tiangolo@svlandeg
, '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

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility) - #1821

Open
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property
Open

🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)#1821
estarfoo wants to merge 1 commit into
fastapi:mainfrom
estarfoo:feat/tablename-property

Conversation

@estarfoo

@estarfooestarfoo commented Mar 18, 2026

Copy link
Copy Markdown

Move __tablename__ from a @declared_attr method on SQLModel to a declaration on SQLModelMetaclass, with the default set in SQLModelMetaclass.__new__ before class creation (unless the user supplied one).

This resolves the type-level contradiction between the ClassVar[str | Callable] declaration and the @declared_attr descriptor, letting __tablename__ = "my_table" work without # type: ignore for pyright.

Caveat: dynamically computed names via @declared_attr.directive (per @YuriiMotov's review below) type-check in pyright basic mode. In standard/strict mode, one reportIncompatibleVariableOverride diagnostic remains.

Tests added for default name, explicit override, inheritance, non-table models, and the @declared_attr.directive form.

Fixes#98.

This is split out from #1820, dropping those changes which would be resolved by #1345 or #1806.

(Description edited following source branch update from 24d66f7 to 415c8fc.)

@svlandegsvlandeg added the bug Something isn't working label Mar 18, 2026
@YuriiMotov

Copy link
Copy Markdown
Member

Spent a little time on reviewing this PR and haven't found any issues so far. All seems to work well.

Would be also good to fix the case with dynamically created table names as well:

from pydantic.alias_generators import to_snake
from sqlalchemy.orm import declared_attr
from sqlmodel import Field, SQLModel
class FirstWidget(SQLModel):
id: int | None = Field(default=None, primary_key=True)
name: str
@declared_attr.directive
@classmethod
def __tablename__(cls) -> str:
return to_snake(cls.__name__)
assert FirstWidget.__tablename__ == "first_widget"

Works well, but pyright argues:

image

@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, thank you very much for the review! Sorry for being slow to get back to this.

I agree that the dynamic table name is a valid use case and deserves support along with the static case. However:

  • SQLAlchemy doesn't seem to export the type for @declared_attr.directive. To match it exactly, I'd have to import the private name, which I assume nobody would want:
fromsqlalchemy.orm.decl_apiimport_declared_directive# …__tablename__: ClassVar[str|Callable[..., str] |_declared_directive[str]]
  • __tablename__ is Any in SQLAlchemy's DeclarativeBase anyway. I can do this:
__tablename__: ClassVar[Any]

But the second option loses typing for table names. Any preference, or do you maybe see another way?

The `SQLModel` base declared `__tablename__` both as
`ClassVar[str | Callable[..., str]]` and as a `@declared_attr` method.
Type checkers (pyright) see the descriptor type from `@declared_attr`, so
`__tablename__ = "my_table"` in a subclass is rejected as a type mismatch
even though it works at runtime; and the `ClassVar` on the base both leaks
into the constructor signature and conflicts with a descriptor override
such as `@declared_attr.directive`.
Move the declaration to `SQLModelMetaclass` as `__tablename__: str`, setting
the default in `SQLModelMetaclass.__new__` (in the class dict, before class
creation) unless the user supplied one. Because the attribute now lives on
the metaclass:
- explicit names (`__tablename__ = "my_table"`) narrow to `str` rather than
the `str | Callable` union, so reads are usable without casts;
- it is not collected as a model field, so it never appears in the
constructor;
- a dynamically computed name via `@declared_attr.directive` type-checks in
pyright basic mode, leaving only a single reportIncompatibleVariableOverride
in standard/strict mode, inherent to overriding a class attribute with a
descriptor.
Tests added for default name, explicit override, inheritance, non-table
models, and the `@declared_attr.directive` form.
Fixesfastapi#98.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@estarfoo
estarfooforce-pushed the feat/tablename-property branch from 24d66f7 to 415c8fcCompareJune 10, 2026 09:38
@estarfooestarfoo changed the title 🐛 Move __tablename__ default from @declared_attr to metaclass🐛 Declare __tablename__ on the metaclass (improve pyright compatibility)Jun 10, 2026
@estarfoo

Copy link
Copy Markdown
Author

@YuriiMotov, following up on dynamic table names: neither option from above turned out to be satisfying, so here's a rework shifting the __tablename__ declaration itself, not just its default value.

The current version would still produce a pyright message. I don't see a way around this at the moment, and I think it's an intrinsic limitation coming from SQLAlchemy. I'm open to suggestions on this, though!

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

Labels

bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pyright cannot recognize the type of SQLModel.__tablename__

4 participants

@estarfoo@YuriiMotov@tiangolo@svlandeg