Why does Alembic need an import you never use?
A coaching session this week surfaced a good question: Alembic autogenerate only works if you import your models first, even though you never reference them.
Ruff flags the import as unused.
So why is it there?
Instead of just adding # noqa: F401, I stopped and followed the call path into SQLModel and SQLAlchemy. The answer is a nice example of Python metaprogramming: importing the module executes the class definition, and the class definition registers its table with SQLAlchemy's metadata.
The unused import that isn't unused
Here's the line every SQLModel + Alembic setup has in env.py:
from tips.models import Tip, User # noqa: F401
target_metadata = SQLModel.metadata
(That's a real line from this project's env.py.)
SQLModel.metadata is a single shared MetaData object. A table only gets registered in its tables collection as a side effect of the class being defined.
If tips.models never gets imported, the class Tip(...) statement never executes, so no table is registered. Alembic then sees empty model metadata when it runs autogenerate. Existing database tables aren't present in that metadata, so Alembic can propose dropping them.
So the import isn't there for the names Tip and User. It's there to run the module so the class definitions fire. That's why # noqa: F401 is justified.
This is a common confusion for people new to Python, importing an object actually reads and executes the module, and the side effects of that execution are often more important than the names it exports. (For a related trap, where when an object gets created changes its behavior, see Two Python Scoping Bugs.)
A better way to write it so we can include any future models without having to list them all is:
import tips.models # noqa: F401
The interesting question is what "the class definition fires" actually means. Nothing in your model class calls metadata.add_table(). So who does?
Following table=True into the source
When you write class Tip(SQLModel, table=True), the table=True is a class keyword argument. It gets routed to SQLModelMetaclass. In sqlmodel 0.0.39, main.py reads it back out and stashes it on the model config:
# sqlmodel/main.py — SQLModelMetaclass.__new__
config_table = get_config("table")
if config_table is True:
new_cls.model_config["table"] = config_table
...
Then __init__ on the same metaclass checks that flag and, only when it's set, hands the class over to SQLAlchemy's declarative machinery:
# sqlmodel/main.py — SQLModelMetaclass.__init__
base_is_table = any(is_table_model_class(base) for base in bases)
if is_table_model_class(cls) and not base_is_table:
... # build columns and relationships from the model's fields
DeclarativeMeta.__init__(cls, classname, bases, dict_, **kw)
else:
ModelMetaclass.__init__(cls, classname, bases, dict_, **kw)
(is_table_model_class is just the check that model_config["table"] is set.)
That if/else is the key fork created by table=True. With it, SQLModel takes the class down SQLAlchemy's declarative path. Without it, you get a SQLModel/Pydantic model rather than a mapped table model, so no SQLAlchemy Table is registered in SQLModel.metadata.
From DeclarativeMeta.__init__ the trail runs straight down into SQLAlchemy:
# sqlalchemy/orm/decl_api.py
if not cls.__dict__.get("__abstract__", False):
_as_declarative(reg, cls, dict_)
_as_declarative scans the class, builds a Table object from the columns, and that Table registers itself:
# sqlalchemy/sql/schema.py — Table.__new__
metadata._add_table(name, schema, table)# sqlalchemy/sql/schema.py — MetaData._add_table
def _add_table(self, name, schema, table):
key = _get_table_key(name, schema)
self.tables._insert_item(key, table)
There it is. self.tables._insert_item(...) is the exact moment your model becomes an entry in SQLModel.metadata.tables, and it runs during class definition, triggered by importing the module. That's the side effect Alembic depends on.
Seeing it happen
You don't have to trust the call path. Registration is a side effect of the class statement, so a plain REPL lets you watch the registry grow in real time:
>>> from sqlmodel import SQLModel, Field
>>> print(SQLModel.metadata.tables)
FacadeDict({})
>>> class Tip(SQLModel, table=True):
... id: int | None = Field(default=None, primary_key=True)
... text: str
...
>>> print(SQLModel.metadata.tables)
FacadeDict({'tip': Table('tip', MetaData(), Column('id', Integer(), table=<tip>, primary_key=True, nullable=False), Column('text', AutoString(), table=<tip>, nullable=False), schema=None)})
No create_engine, no create_all, no import of your models module. Just defining the class populated the shared metadata. Drop the table=True and run it again: the dict stays empty, because now Tip is a SQLModel/Pydantic model rather than a mapped table model.
That proves the class statement does the registering. To tie it back to the opening question, put the same class in a module and let the import fire it:
# models.py
from sqlmodel import SQLModel, Field
class Tip(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
text: str>>> from sqlmodel import SQLModel
>>> print(SQLModel.metadata.tables)
FacadeDict({})
>>> import models # the "unused" import
>>> print(SQLModel.metadata.tables)
FacadeDict({'tip': Table('tip', ...)})
You never touch models.Tip after importing it, yet the registry filled up. That's the exact env.py situation: the import runs the module, the module runs the class definition, and the class definition registers the table.
Is this metaprogramming?
SQLModelMetaclass inherits from both Pydantic's ModelMetaclass and SQLAlchemy's DeclarativeMeta.
This is the same mechanism behind a lot of Python you use daily. If you've ever wondered how a class declaration can acquire behavior you never explicitly wrote, the answer is often a metaclass or __init_subclass__ doing work at definition time.
A metaclass is code that runs when a class is defined. Here it inspects a keyword, processes the model's fields, and delegates table-model construction to SQLAlchemy.
Your field annotations get read at that moment and turned into Column objects.
The practical payoff: next time autogenerate produces an empty migration or wants to drop all your tables, you'll know the cause is registration order, not Alembic being broken. Something imported the models too late, or not at all, and metadata.tables was empty when Alembic read it.
Looking at libraries
Reading a library's source to answer "why is this import here" is a great way to learn the library and Python itself. You don't have to understand every line. Follow the call path of a single feature and you'll often see how the pieces fit together.
It's also about not taking things at face value. A noqa means you're deliberately silencing something a linter flagged. Here it was warranted, but only after understanding why the import exists.
AI can follow this call path for you in seconds. That's useful.
But the valuable skill isn't memorizing that Table.__new__ calls _add_table. It's having the instinct to stop when something looks strange and ask: why is this here?
As AI writes more of the code, this habit matters now more than ever I think. If you don't understand the code, you can't maintain / improve it over time.
AI is an accelerator, not a compass. I coach developers to ship faster and understand what they ship. See the path →