Python Parsing Limitations¶
erdify reads your models with the standard library's ast module. It never
imports them, so it sees the text of the file, not the classes Python would
build from it. Almost every limitation below follows from that one fact.
The SQL DDL frontend has its own list.
How to tell
Render to --format json and look at what erdify actually extracted. It is
the fastest way to confirm whether a field, key or relationship was seen.
See Troubleshooting.
Only scanned files exist¶
Class names, relationship targets and foreign keys are resolved within the set
of files the scan selected. --include defaults to models.py, so a model
that lives anywhere else is invisible — and a relationship pointing at it
silently produces no line:
# app/models.py # other/entities.py holds Team
class Hero(SQLModel, table=True):
team_id: int = Field(foreign_key="team.id")
team: Optional["Team"] = Relationship()
erdify draws Hero with a foreign_key(team_id) column and no relationship
to Team, because Team was never parsed. Widen the scan:
erdify . --include models.py entities.py
Anything built at runtime¶
| Construct | What erdify does |
|---|---|
Model = type("Model", (SQLModel,), {...}) |
Invisible — there is no class statement to walk. |
| A class defined inside a factory function | Picked up, but under its source name: class _Generated inside make_model() becomes entity _Generated / table __generated, not whatever the factory returns. |
Fields attached with setattr() or in a loop |
Invisible. Only class-body assignments and annotations are read. |
Fields under if TYPE_CHECKING: |
Invisible. |
| Models registered by a third-party package | Invisible unless their source is in the scanned files. |
A base class defined outside the scan (ninja.Schema, a shared internal BaseSchema) |
Its subclasses are skipped, because the inheritance chain to BaseModel cannot be resolved. Name it with --base-classes. |
If your schema is genuinely assembled at runtime, an import-based tool is the right instrument — see the comparison.
Non-literal __tablename__¶
The table name is read only when it is a string literal. Anything else falls back to the class-name conversion, without a warning:
class Computed(SQLModel, table=True):
__tablename__ = "prefix_" + "computed" # erdify reports: computed
The same applies to a declared_attr-computed __tablename__. Django's
Meta.db_table is read when it is a literal.
__table_args__ is not read¶
Nothing inside __table_args__ reaches the diagram:
class Match(Base):
__table_args__ = (
ForeignKeyConstraint(["season_year", "season_league"],
["season.year", "season.league"]),
UniqueConstraint("slug"),
Index("ix_match_played_at", "played_at"),
)
- Composite foreign keys are missed entirely — the columns appear as plain columns and no relationship is drawn. (Composite primary keys are fine; see below.)
UniqueConstraintandIndexdo not set theUK/ index markers. Only per-columnunique=/index=arguments do.schema=is ignored; entities are not namespaced.
Django's Meta.unique_together and Meta.indexes are ignored for the same
reason.
Composite keys¶
Composite primary keys work. Several columns marked primary_key=True are
all rendered as primary keys, and the structural link-table detection depends on
exactly that.
Composite foreign keys do not, because SQLAlchemy can only express them
through __table_args__ (above). A multi-column foreign key produces plain
columns and no relationship line.
Foreign keys hidden in sa_column¶
erdify reads the foreign key from Field(foreign_key="team.id"),
mapped_column(ForeignKey("team.id")) and Django's relationship fields. It does
not look inside a sa_column=Column(...):
# not detected
team_id: Optional[int] = Field(default=None, sa_column=Column(Integer, ForeignKey("team.id")))
# detected
team_id: Optional[int] = Field(default=None, foreign_key="team.id")
Relationships need an annotation¶
The relationship target comes from the annotation, not from the call argument. An annotated attribute works whether the target is a real name or a string; a bare assignment is ignored:
annotated: Mapped["Coach"] = relationship("Coach") # drawn
bare = relationship("Coach") # ignored
Django is the exception: its relationship fields are calls, so string targets
("Author", "catalog.Author", "self") and direct class references are all
resolved from the call argument.
Keyless models¶
Pydantic models and dataclasses have no keys to read. Without --infer-keys,
id and <x>_id stay ordinary columns and no relationships are drawn — see
Filtering & Key Inference. Inference is a heuristic on
names, so it can be wrong in both directions.
Deferred / Not supported¶
| Item | Status |
|---|---|
Runtime-constructed models (type(), registries) |
Not supported by design (no imports) |
Fields added via setattr / loops / if TYPE_CHECKING |
Not supported |
Non-literal __tablename__ / declared_attr |
Falls back to the class name, silently |
__table_args__ (composite FKs, UniqueConstraint, Index, schema=) |
Not read |
Django Meta.unique_together / Meta.indexes |
Not read |
| Composite foreign keys | Not supported |
Foreign keys inside sa_column=Column(...) |
Not detected |
Unannotated x = relationship("Target") |
Not detected |
ninja-style ModelSchema (fields from an inner Meta/Config) |
Detected and skipped with a warning, rather than drawn empty (#171) |
attrs / msgspec models |
Not supported (erdantic covers these) |
| Live database introspection | Not supported by design |
Hitting one of these in a way that looks fixable within the AST approach? Please open an issue — several of these are "not implemented yet" rather than "impossible".