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] [--strict] [--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    Draw no relationship lines (edges) in any format,
                        foreign-key edges included
  --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)
  --strict              Fail (exit 1, write nothing) if any model or .sql file
                        cannot be parsed. Without it, such a file is reported
                        on stderr and skipped
  --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
strict = true                       # fail if any model/.sql file cannot be parsed
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. A response schema is a projection of a table, not a table, so erdify does not draw it; see issue #171 for the reasoning. If a projection view would help you, say so there.

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.

Files that cannot be parsed (--strict)

By default, a model or .sql file that erdify cannot parse (a syntax error, a file that isn't valid SQL) does not stop the run. erdify prints Error parsing <file>: ... on stderr, skips the file and renders the rest, and the exit code is 0. That keeps a single broken or work-in-progress file from blocking everything else, but the diagram is then silently missing that file's entities.

--strict turns this into an error. erdify still reports every failing file, then exits 1 without writing anything, so an existing diagram is left untouched:

erdify ./src/database -o docs/erd.puml --strict

or, permanently:

[tool.erdify]
strict = true

--strict is a good fit for CI: together with --check, it fails the job both when the committed diagram is stale and when part of the schema could not be read. From Python, pass strict=True to parse_models_directory (or to ASTDatabaseParser / SqlSchemaParser). It raises erdify.ModelParseError, whose errors attribute lists every (file, message) pair.

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, 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'). Its options mirror the CLI flags (include_patterns, exclude_patterns, sources, infer_keys, sql_dialect, ...).

generate_mermaid, generate_json and generate_html take the same arguments as generate_plantuml and return the other output formats as a string.

Programmatic Access

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

from pathlib import Path
from erdify import (
    ASTDatabaseParser,
    SqlSchemaParser,
    PlantUMLGenerator,
    MermaidGenerator,
)

# Python models only
parser = ASTDatabaseParser(Path("./models"), include_patterns=["models.py", "models/*.py"])
entities, enums = parser.parse_all_models()

# SQL DDL only (requires erdify[sql]); takes an explicit list of files
sql_entities, sql_enums = SqlSchemaParser([Path("schema.sql")], dialect="postgres").parse()

# 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}")

puml = PlantUMLGenerator(entities, enums, title="Custom ERD").generate()
mmd = MermaidGenerator(entities, enums, title="Custom ERD").generate()

SqlSchemaParser.parse() raises erdify.SqlDependencyError when the optional sqlglot dependency is missing.

What is public

Everything listed in erdify.__all__ is the public Python API. Within it:

Name Public contract
parse_models_directory, generate_plantuml, generate_mermaid, generate_json, generate_html The function and its parameters. Only the first argument (and enums for the generate_* functions) may be passed positionally; everything else is keyword-only.
ASTDatabaseParser The constructor (database_path positional, options keyword-only) and parse_all_models().
SqlSchemaParser The constructor (files positional, options keyword-only) and parse().
PlantUMLGenerator, MermaidGenerator The constructor (entities, enums positional, title keyword-only) and generate().
EntityInfo, FieldInfo, EnumInfo The dataclass fields. New fields may be added with defaults.
SqlDependencyError The exception type.
ModelParseError The exception type and its errors attribute ((file, message) pairs).
JSON_SCHEMA_VERSION The current JSON output schema_version.

Any other attribute or method on these classes, and anything imported from a submodule (erdify.parser, erdify.generator, ...) rather than from erdify, is internal and may change in any release.