Skip to content

CLI & Python API

erdify ships a command-line interface, a module entry point, and a Python API for programmatic use.

CLI Options

usage: erdify [-h] [-o OUTPUT] [--title TITLE] [--exclude [PATTERN ...]]
              [--exclude-paths [PATTERN ...]] [--no-default-excludes]
              [--sources [KIND ...]] [--include PATTERN [PATTERN ...]]
              [--base-classes NAME [NAME ...]] [--sql-dialect NAME]
              [--infer-keys] [--django-raw-types] [--no-enums]
              [--no-relationships] [--format FMT [FMT ...]] [--inject FILE]
              [--allow-empty] [--check] [-v]
              input

Generate PlantUML ERD diagrams from SQLModel, SQLAlchemy, Django, Pydantic and
dataclass models

positional arguments:
  input                 Directory or file with models (.py) or SQL DDL (.sql)

options:
  -h, --help            show this help message and exit
  -o, --output OUTPUT   Output .puml file (default: stdout)
  --title TITLE         Diagram title (default: 'Database ERD')
  --exclude [PATTERN ...]
                        Glob patterns (case-sensitive) to exclude entities by
                        class name or table name, e.g. --exclude '*Link'
                        audit_log
  --exclude-paths [PATTERN ...]
                        Glob patterns (case-sensitive) for models.py files to
                        skip before parsing, matched against the path relative
                        to input or any path segment, e.g. --exclude-paths
                        migrations legacy 'apps/experimental/*'
  --no-default-excludes
                        Do not auto-skip models.py under venv/site-
                        packages/cache dirs (.venv, site-packages,
                        __pycache__, ...); scan them too
  --sources [KIND ...]  Restrict which model kinds become entities. Choices:
                        sqlmodel, sqlalchemy, django, dataclass, pydantic.
                        Default: all. e.g. --sources sqlmodel sqlalchemy for
                        DB tables only
  --include PATTERN [PATTERN ...]
                        Glob patterns for files to scan (default: models.py).
                        A pattern with '/' matches the path relative to input
                        ('**' crosses dirs); a pattern without '/' matches a
                        filename at any depth. Replaces the default, so list
                        models.py too if you want it, e.g. --include models.py
                        '**/models/*.py' tables.py
  --base-classes NAME [NAME ...]
                        Extra base-class names to treat as Pydantic models,
                        for bases defined outside the scanned files (erdify
                        resolves ancestors only across those), e.g. --base-
                        classes Schema for django-ninja, or a shared
                        BaseSchema from an internal library
  --sql-dialect NAME    SQL dialect hint for parsing .sql DDL with the [sql]
                        extra (e.g. postgres, mysql, sqlite). Default: a
                        permissive generic read
  --infer-keys          For keyless models (Pydantic/dataclass), infer a
                        primary key from a field named 'id' and a foreign key
                        from '<x>_id' (target table '<x>')
  --django-raw-types    For Django models, show original field names
                        (CharField, TextField) instead of mapped Python types
                        (str, int, datetime)
  --no-enums            Skip enum definitions in output
  --no-relationships    Skip relationship lines in output
  --format FMT [FMT ...]
                        Output format(s): plantuml (.puml), mermaid (.mmd),
                        json (.json), html (.html). Default: plantuml. With -o
                        the extension is set per format; multiple formats
                        require -o, e.g. --format plantuml mermaid
  --inject FILE         Inject the diagram into a markdown file between '<!--
                        erdify:start -->' and '<!-- erdify:end -->' markers
                        (only that region is rewritten). Uses a single
                        --format (default mermaid). Combine with --check to
                        fail on drift, e.g. --inject README.md
  --allow-empty         Treat 'no tables found' as a warning instead of an
                        error: write the empty diagram and exit 0 (default
                        since 0.13.0: exit 1)
  --check               Don't write; exit non-zero if the --output file is
                        missing or differs from the freshly generated diagram
                        (for CI / pre-commit drift checks)
  -v, --version         show program's version number and exit

Example: erdify ./src/database -o database_erd.puml

The block above is generated from erdify --help; run python scripts/gen_cli_docs.py after changing CLI flags.

Configuration via pyproject.toml

Instead of passing flags every time, commit them under [tool.erdify] in your project's pyproject.toml. erdify searches upward from the input directory for the nearest pyproject.toml and reads that table.

[tool.erdify]
title = "My Database Schema"
output = "docs/database_erd"        # relative paths resolve from the project root
format = ["plantuml", "mermaid"]    # one or both
sources = ["django"]
exclude = ["audit_log", "*Link"]
exclude_paths = ["migrations", "legacy"]
base_classes = ["Schema"]           # extra Pydantic bases defined outside the scan
infer_keys = true
django_raw_types = false
allow_empty = true                  # downgrade "no tables found" to a warning
sql_dialect = "postgres"            # required for CREATE TYPE … AS ENUM support

With that in place, erdify . uses these settings. Precedence is explicit CLI flag > [tool.erdify] value > built-in default. (Boolean flags merge by OR: a flag enabled in config can be added to on the CLI but not turned off there.)

Bases defined outside the scan (--base-classes)

erdify classifies a class as a Pydantic model when BaseModel appears in its bases — and it resolves ancestors only across the files it scanned. A base class that lives in an installed package, or in a module --include does not match, is therefore unresolvable and its subclasses are skipped entirely.

The common case is django-ninja. ninja.Schema does subclass pydantic.BaseModel, but that inheritance is inside the installed package, which erdify never reads:

from ninja import Schema

class AuthorOut(Schema):      # not recognized by default
    id: int
    name: str

Name the base and it is treated as a Pydantic model:

erdify ./api --base-classes Schema --infer-keys
[tool.erdify]
base_classes = ["Schema", "BaseSchema"]

This works for a shared base in your own internal library just as well — the BaseSchema every service imports from a company package is the same problem. Both the bare form (Schema) and the qualified form (ninja.Schema) are matched, and a class that reaches the named base through an intermediate defined in a scanned file is picked up too.

Model-derived schemas are a different matter

ninja.ModelSchema and ninja_schema.ModelSchema take their fields from a Django model via an inner Meta / Config class, leaving the class body empty. erdify does not resolve that reference, so naming those bases would produce entities with no fields. It detects the shape and skips them instead, with a note on stderr:

Warning: skipped UserSchema - its fields come from Meta.model (User), which
  erdify does not resolve. Drawing it would add an entity with no fields.

The Django model itself is still drawn as usual. Whether a response schema belongs in an ERD at all — it is a projection of a table, not a table — is an open question in issue #171; if you have a view, that is the place for it.

Empty results (--allow-empty)

When a run finds no tables, erdify reports it on stderr — naming the active --include patterns, how many .py/.sql files it scanned and how many of them matched — and exits 1 without writing anything, so a file already on disk is left untouched.

Changed in 0.13.0

Up to 0.12.3 this was a warning: erdify wrote an empty diagram and exited 0. In a CI job that commits the result, that silently replaced a good ERD with an empty one whenever --include stopped matching — a rename or a moved package was enough. Zero entities is now an error by default.

A schema can legitimately be empty — every entity filtered out by --exclude, a --sources filter that matches nothing, a schema mid-migration. Opt out there:

erdify ./src/database -o docs/erd.puml --allow-empty

or, permanently:

[tool.erdify]
allow_empty = true

That restores the old behavior exactly: the message becomes a warning, the empty diagram is written, and the exit code is 0.

Keeping the diagram in sync (--check)

--check regenerates the diagram in memory and compares it to the existing --output file or the injected region in an --inject target without writing. It exits 0 when the content is up to date and non-zero when the file is missing or stale — ideal for CI or a pre-commit hook.

erdify ./src/database -o docs/erd.puml --check
erdify ./src/database --inject README.md --check

Running as Module

python -m erdify ./src/database -o erd.puml

Python API

from pathlib import Path
from erdify import parse_models_directory, generate_plantuml

# Parse Python models
entities, enums = parse_models_directory(Path("./src/database"))

# Parse SQL DDL (requires erdify[sql])
entities, enums = parse_models_directory(Path("schema.sql"), sql_dialect="postgres")

# Generate PlantUML
diagram = generate_plantuml(
    entities=entities,
    enums=enums,
    title="My Database ERD"
)

# Save or use the diagram
Path("erd.puml").write_text(diagram)

parse_models_directory accepts both a Python models directory and a .sql file (or a directory scanned with --include '*.sql'). For Python models only, the lower-level ASTDatabaseParser class is also available — it does not handle .sql input, so use parse_models_directory when working with SQL DDL.

Programmatic Access

For lower-level control, use the parser and generator classes directly:

from erdify import (
    ASTDatabaseParser,
    PlantUMLGenerator,
    EntityInfo,
    FieldInfo,
    EnumInfo,
)

# Low-level parser access (Python models only — use parse_models_directory for .sql)
parser = ASTDatabaseParser(Path("./models"))
entities, enums = parser.parse_all_models()

# Access entity details
for name, entity in entities.items():
    print(f"Table: {entity.table_name}")
    for field in entity.fields:
        if field.is_primary_key:
            print(f"  PK: {field.name}")
        elif field.is_foreign_key:
            print(f"  FK: {field.name} -> {field.foreign_table}")

# Custom generator with options
generator = PlantUMLGenerator(
    entities=entities,
    enums=enums,
    title="Custom ERD"
)
output = generator.generate()