erdscope #

erdscope generates a self-contained, interactive ER diagram — and, optionally, an Excel table-definition workbook — from a live MySQL or PostgreSQL database. It ships as a single Python file (erd.py, no install step) with zero required dependencies.

The database is the source of truth: tables, columns, comments, indexes, and real foreign keys all come from the database catalog (information_schema on MySQL, pg_catalog on PostgreSQL). Application code (Rails, Prisma, Django) can optionally be layered on top with --models to add association semantics the database alone can't express — has_many :through, polymorphic associations, and so on.

Try the live demo → — a small e-commerce schema with comments, indexes, real FKs, and one inferred relation. Everything in the demo is a single self-contained HTML file, generated the same way your own diagram will be.

erdscope diagram showing an e-commerce schema with users, orders, products and their relations

Database truth

Tables, columns (full SQL types, defaults, extras), comments, indexes, and real FK constraints — read directly from the database.

Code semantics on top

--models merges in Rails/Prisma/Django associations; DB FKs already covered by an association are deduplicated, the rest get a "DB FK" badge.

Interactive exploration

Focus with depth and direction, two-level hiding, table and column search, named views, share links.

Readable layouts

Viewport-aware packing, drag-to-snap, multi-select align/distribute, Auto-tidy, undo/redo.

Exports

PNG, SVG, Mermaid, PlantUML — each with copy and download — plus an Excel workbook.

Logical names

A table's DB comment doubles as a searchable logical name, shown alongside the physical name.

Installation & requirements #

Install from PyPI to get the erdscope command:

pip install erdscope        # or: pipx install erdscope
erdscope mysql://readonly@127.0.0.1:3306/myapp -o erd.html

Prefer not to install anything? erd.py is a single, dependency-free file — download it and run it with any Python 3.9+, identically to the erdscope command used throughout this manual:

curl -O https://raw.githubusercontent.com/orapli/erdscope/main/erd.py
python3 erd.py mysql://readonly@127.0.0.1:3306/myapp -o erd.html

Requirements: Python 3.9+, nothing else, strictly speaking. Two libraries make the tool nicer to use but are entirely optional — erdscope degrades gracefully when they're missing. With the pip package, extras pull them in for you: pip install 'erdscope[mysql]' (PyMySQL), 'erdscope[postgres]' (psycopg), 'erdscope[yaml]' (PyYAML), or 'erdscope[all]':

LibraryUsed forIf not installed
PyMySQLMySQL connectionsFalls back to shelling out to the mysql CLI (must be on PATH) — see Troubleshooting
psycopg (or psycopg2)PostgreSQL connectionsFalls back to shelling out to the psql CLI (must be on PATH) — see Troubleshooting
PyYAMLReading a .yml/.yaml config fileA .json config still works with zero dependencies; pointing at a .yml/.yaml file without PyYAML exits with a clear error

--excel output needs none of the above — it's written directly via the Python standard library's zipfile/XML handling, not a spreadsheet package.

Two more libraries are used only by the test suite, never at runtime: openpyxl (roundtrip-verifies --excel output in unit tests) and Playwright (drives the generated HTML in the browser E2E suite). Neither is needed to generate or view diagrams.

Quickstart #

erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html

# enrich with association semantics parsed from application code (optional)
erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
        --models /path/to/rails/app -o erd.html

# also write a table-definition workbook
erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
        --excel table_definitions.xlsx -o erd.html

# PostgreSQL: same thing — schema defaults to public, override with ?schema=name
erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html

Open the resulting erd.html in any browser — it's a single file with everything inlined, so it can be emailed, committed to a wiki, or dropped into a shared drive.

Passwords

Use a read-only database account, and leave the password out of the connection URL — it would otherwise land in your shell history. erdscope resolves the password in this order:

  1. Password embedded in the URL (mysql://user:pass@host/db) — avoid this in practice.
  2. The MYSQL_PWD (MySQL) / PGPASSWORD (PostgreSQL) environment variable, if set.
  3. Otherwise, if standard input is an interactive terminal, a hidden password prompt (never touches argv or shell history).
Non-interactive runs (CI, scripts, pipes) erdscope only prompts when stdin is a TTY. In a script or CI job it skips the prompt entirely rather than blocking — it just attempts the connection with whatever password it has (possibly none), and if that's wrong you get a normal connection-error message, not a hang. Writing user:@host/db (an explicit, empty password) also counts as "password provided" and skips the prompt. On PostgreSQL, answering the prompt with an empty line leaves PGPASSWORD unset, so libpq's own ~/.pgpass lookup still applies.

Behind a bastion / SSH tunnel

ssh -N -L 3307:db-host:3306 bastion &
erdscope mysql://readonly@127.0.0.1:3307/myapp_production -o erd.html

CLI reference #

This table is verified against erd.py's argparse definitions — run erdscope --help yourself any time to confirm it against the copy you're running.

erdscope [mysql://user@host/db | postgres://user@host/db] [options]
ArgumentDescription
mysql://… or postgres://…Positional. Database connection URL. postgres:// accepts an optional ?schema=name (default public); postgresql:// works too. Can also be assembled from engine/host/port/user/database in the config file (no password field there — see Config file).
-o, --output OUTPUTOutput HTML file (default: erd.html)
--models PATHMerge association semantics parsed from application code: a Rails project (or app/models dir), a schema.prisma, or a Django project — source auto-detected
--excel FILE.xlsxAlso write a table-definition workbook: an overview sheet plus one sheet per table
--excel-template FILE.xlsxOverride the workbook's colors/fonts/borders from a template .xlsx — see Exports for the 5-cell contract. Has no effect (and prints a warning) without --excel
--max-rows NMax column rows shown per table before scrolling (default: 15)
--only 'user*,post*'Include only tables matching the glob pattern(s). Repeatable; comma-separated lists accepted
--exclude '*_logs'Exclude tables matching the glob pattern(s). Same syntax as --only
--infer-fkGuess relations from *_id column names when no real association/FK backs them. Off by default — see Troubleshooting
--table-map 'Widget=crm_widgets'Rails only: override a model's table when static analysis can't determine it. Repeatable; comma-separated lists accepted — see Troubleshooting
--config PATHLoad defaults from a config file. Auto-discovered as .erdscope.json/.yml/.yaml in the current directory if not given
--no-configSkip config auto-discovery even if .erdscope.* exists in the cwd

Config file #

Once the flag list gets long, put it in a file instead. .erdscope.json (or .erdscope.yml/.yaml, if PyYAML is installed) next to where you run the tool is picked up automatically — no --config needed. Most keys mirror a CLI option above (snake_case); engine/host/port/user/database (the DB connection — engine is "mysql", the default, or "postgres", which also flips the default port to 5432) and relations (manual FK declarations) are config-only, with no CLI equivalent.

{
  "host": "127.0.0.1",
  "port": 3306,
  "user": "readonly",
  "database": "myapp_production",

  "output": "erd.html",
  "models": "../myapp",
  "max_rows": 15,
  "infer_fk": true,
  "only": ["user*", "post*"],
  "table_map": { "Widget": "crm_widgets" },

  "relations": [
    { "table": "orders", "column": "buyer_code", "references": "users" },
    { "table": "profiles", "column": "person_ref", "references": "users",
      "one_to_one": true, "name": "owner" }
  ]
}

An explicit CLI flag always wins over the same config key, replacing it entirely (list-valued keys like only are not merged with the config's). There's deliberately no password/url key — host/port/user/database are separate fields specifically so there's nowhere to paste a password into. Leave it out the same way you would on the CLI: MYSQL_PWD/PGPASSWORD, ~/.my.cnf/~/.pgpass, or the interactive prompt.

See erdscope.example.yml for a fully annotated sample (based on the live demo's schema) with every key explained.

Manual relations

relations declares a relation no other source (a real FK constraint, *_id name inference, or --models code parsing) can find — an oddly-named column, or one a gem-provided concern/dynamic association hides from static analysis. It works standalone, with no --models at all — you can build a complete relation graph from a config file alone. Precedence is the same as a code-parsed association: applied before --infer-fk runs (so it also suppresses a wrong name-based guess for that column), and it takes priority over a real DB FK constraint for the same column. An unknown table/column/target in relations is always a typo, so it's a hard error, not a silent no-op.

Config value types matter Keys are validated by both name and type before erdscope runs. only/exclude must be a list of strings, not a bare string (a plain string would otherwise be matched character-by-character rather than as one pattern), and infer_fk must be a real JSON boolean, not the string "true"/"false". A mismatched type is now a clear upfront error rather than a silently wrong diagram — if you hit one, check the value's type against the example above.

Viewer guide #

Everything below describes the generated HTML file itself — the diagram you open in a browser, not the CLI that produced it. Try each control against the live demo as you read.

Panes #

The page is three panes: Tables (left) — the full table list with checkboxes and search — the diagram (center), and Details (right) — columns, indexes, and associations for whatever's selected.

  • Resize — drag the thin divider between a side pane and the diagram (width is clamped between 140px and 560px).
  • Collapse / expand — the ◀/▶ button in a pane's title bar collapses it; a small tab reappears on that edge to bring it back.
  • Pane widths and collapsed state are remembered by your browser for next time you open this diagram.

Focus & exploration #

Double-click a table (in the list or on the canvas) to focus on it — the diagram narrows to that table and its related tables. Double-clicking the already-focused table, pressing Esc, or the ✕ Back to overview / Exit focus buttons all return to the full overview.

While focused, a bar across the top shows 🔍 Focused: <table> (depth <d>, <direction>). Checkboxes in the left pane only affect the overview, not the focused view — a hint says so, and Apply to checks checks exactly the tables the focus view is currently showing into the overview, then exits focus.

Three controls shape what focus (and Auto-expand, below) pulls in:

ControlOptionsEffect
Depth1 / 2 / 3 / How many relation hops out from the focused table to include
DirectionBoth / Deps / DependentsDeps follows what this table depends on (parents it references via FK); Dependents follows what depends on this table (children referencing it). This also governs each table's button, regardless of Auto-expand.
Auto-expandtoolbar checkboxWhen on, every checked table in the overview acts as a traversal root, pulling in its neighbors up to the current depth/direction. Focus mode always expands from the focused table regardless of this setting.

Each node also has a button that pulls in just that table's direct neighbors (respecting the current direction) as a one-off — it doesn't itself become a new expansion root, so one click can't cascade the whole schema in.

Focused view on the orders table at depth 2, Deps direction, showing orders and its upstream dependencies (shipments, users, addresses)

Focused on orders — depth 2, Deps direction. The focus bar shows both settings; the toolbar's depth/direction controls only appear while focused or Auto-expand is on.

Hiding tables — two levels #

erdscope has two independent ways to remove a table from view, with different strength:

ExcludeBan (hide)
TriggerUncheck its box in the list, or the node's buttonThe list row's 🚫 button only
StrengthLight — can come back via Auto-expand as someone else's neighbor, or by re-checking the box / another table's Hard — never shown again, even by Auto-expand, until unbanned
UndoRe-check the box🚫 again on that row, or Unban all in the red banner that appears while anything is banned

Banning the currently-focused table exits focus. Both excluded and banned sets are remembered by your browser and are captured in named views and share links.

Left pane showing a red 'banned: 1 table(s)' banner with an Unban all link, and the products row struck through and greyed out below it

products banned via the list's 🚫 button — struck through, checkbox locked, and counted in the red banner above.

There are two different search boxes, and they do different things:

Filter (left pane, "Search tables / columns…")

Matches table name, column name, column comment, and table comment. Live-filters the table list as you type. Enter jumps to the best match in the diagram (pulling it back into view if needed) and highlights the matched column.

Highlight (toolbar, "Highlight…")

Same match fields, but never removes anything from view — it marks matching tables/columns and dims the rest, purely as an overlay. Enter/Shift+Enter step to the next/previous match. Unlike selection, a Highlight query is preserved into PNG/SVG exports.

Each box has its own, independent Aa (match case) and .* (regular expression) toggle — turning on regex mode in the filter box doesn't affect the Highlight box.

Toolbar Highlight box with the query 'user', showing amber-outlined matching tables and columns while unrelated tables are dimmed but still present

Highlighting user — matching tables/columns get an amber outline, everything else dims, but nothing is removed from the diagram.

Layout & selection #

  • Pan — drag empty canvas, or two-finger scroll.
  • Zoom — pinch / Ctrl+scroll, or the toolbar + / / 1:1 / ⊡ Fit buttons.
  • Multi-select — click selects one table; Shift-click or Ctrl/Cmd-click adds/removes it from the selection; Shift-drag from empty canvas draws a rubber-band. Clicking empty canvas deselects everything.
  • Drag to move — dragging a selected table moves the whole selection together. Dragged edges snap to nearby tables' edges/centers within a few pixels, drawn as red guide lines (Figma-style) — hold Alt to disable snapping for a drag.
  • Align / distribute — with 2+ tables selected, the right pane offers ⇤ Left / ⇡ Top / ↔ Center / ↕ Middle; with 3+ selected, ⇔ Horiz. / ⇕ Vert. distribute becomes available too (it keeps the two outermost tables fixed and equalizes the gaps between the rest).
  • Auto-tidy — a toolbar toggle that re-packs and re-fits the layout automatically whenever the set of displayed tables changes. The button re-lays-out immediately on demand, regardless of Auto-tidy.
  • Undo/redo — toolbar / buttons or Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z (or Ctrl/Cmd+Y). Covers table position changes only; the history clears whenever you enter/exit focus or load a saved view, since those replace the whole layout.
Three tables (payments, shipments, order_coupons) multi-selected and highlighted blue, with the right pane's Align and Distribute button groups both enabled

Three tables selected — with 3+, both Align and Distribute are available (2 selected enables Align only).

Column display modes #

A toolbar control switches every table between All columns, PK/FK only, and Name only (table names, no columns). Each node also has its own button that cycles that one table's mode independently — handy for zooming in on one big table while keeping the rest compact. A global mode change resets any per-table overrides.

Logical names #

A table's DB comment doubles as a searchable "logical name" — e.g. users(Customer accounts). A toolbar toggle picks Both / Physical / Logical for the live view. Exports have their own, independent Both/Phys./Log. choice inside the export panel (see Exports) — changing what you're looking at doesn't change what gets exported, and vice versa.

Named views & share links #

💾 Save prompts for a name and stores the current view under it (an existing name overwrites). A saved view captures: excluded tables, banned tables, Auto-expand, depth, direction, column mode, and every currently-displayed table's position.

What a saved view does not capture Name mode (Both/Physical/Logical), dark mode, max-rows, and label visibility are not part of a saved view or share link — they're separate, page-wide preferences.

Load a saved view from the Views… dropdown, or delete it with 🗑. 🔗 copies a share link — the current view, JSON-encoded, appended to the page's own URL. Opening that link reproduces the view automatically, so you can paste it straight into a PR description or chat.

Edge types #

Three kinds of relation feed the diagram, but they render as two distinct line styles on the canvas, plus badges in the Details pane:

  • Solid line — the default: a declared association (from --models) or a real DB FK constraint. On the canvas these two look the same; the Details pane's association list distinguishes them with a DB FK badge for constraint-backed edges, or no badge for a plain declared association.
  • Dashed line — a many-to-many relation (only reachable via a join table / has_and_belongs_to_many / through, no direct belongs_to/has_one).
  • Faint dotted line — a name-based guess from --infer-fk, shown only when every association backing that edge is inferred. Marked with a inferred badge in the Details pane. The column-list "FK" badge is never granted by inference alone — only a real association earns it.

A manual badge marks an association that came from the config file's relations list. A dashed node border (as opposed to a dashed edge) means that table was pulled in by Auto-expand rather than checked directly.

Keyboard shortcuts #

This is the complete list — there's no shortcut for zoom, save, or export. The toolbar's ? button shows a condensed version of it right inside the viewer, along with the mouse gestures and a link back to this manual.

KeyAction
EscContext-dependent, in order: close an open toolbar menu (Export or ? help) → clear the filter box if it's focused and non-empty → clear the Highlight box the same way → exit focus mode → otherwise, deselect everything
Ctrl/Cmd+ZUndo the last layout change
Ctrl/Cmd+Shift+Z or Ctrl/Cmd+YRedo
Enter (in the filter box)Jump to the best matching table/column
Enter / Shift+Enter (in the Highlight box)Next / previous match

Dark mode #

The 🌙 toolbar button toggles dark mode for the diagram. It's a manual toggle only — the viewer does not follow your OS/browser color scheme automatically — and your choice is remembered by your browser for next time. Exports (PNG/SVG) always render with the light palette, regardless of which mode you're currently viewing in.

Print #

There's no dedicated print button — use your browser's own print command (Ctrl/Cmd+P). A print stylesheet hides all the chrome (panes, toolbar, legend, focus bar) automatically, leaving just the diagram on a plain white background.

Exports #

The toolbar's ⬇ Export button opens a panel with image options at the top and one row per format below, each with separate Copy and Download buttons — copying and downloading were deliberately split into distinct actions so a browser that can write images to the clipboard doesn't stand between you and just saving a file.

Export panel open, showing Image options (Join-table labels checked, root badges unchecked, Names: Both) above four format rows — PNG, SVG, Mermaid, PlantUML — each with Copy and Download buttons

The export panel: image options at the top, one Copy/Download row per format below.

Image options PNG / SVG only

These two checkboxes and one toggle apply only to the PNG and SVG exports — Mermaid, PlantUML, and the Excel workbook are unaffected by them:

  • Join-table labels (⇢) — on by default; turn off to exclude the small "via join table" edge labels from the exported image.
  • ✓ root badges — off by default; turn on to exclude the checkmark badges that mark Auto-expand roots from the exported image.
  • Names: Both / Phys. / Log. — independent of the live view's own name-mode toggle (Viewer guide); lets you export logical-only names for a stakeholder deck while still viewing physical names yourself.

A live Highlight search is preserved into both PNG and SVG exports (unlike table/edge selection, which is always stripped) — the point is being able to paste a highlighted diagram straight into a doc.

PNG

Rendered by drawing the diagram as SVG, loading it into an offscreen <canvas>, and rasterizing at up to scale — clamped down (never up) if that would exceed an ~8000px canvas dimension, since browsers cap canvas size well below that and silently fail beyond it. Copy writes directly to the clipboard, falling back to a file download automatically if the browser can't write images to the clipboard. Download always saves a file.

SVG

Copy copies the raw SVG markup as text (falling back to download on failure); Download saves an .svg file. The exported SVG always uses the light color palette, independent of the live view's current dark/light mode.

Mermaid

Generates an erDiagram block covering whatever's currently displayed — focus, hidden, and excluded state all apply, so this isn't necessarily the whole schema. Each table becomes an entity block listing column name, type, and a PK/FK marker; each edge becomes a relationship line using Mermaid's crow's-foot notation, labeled with the first association's name. Column comments, nullability, defaults, and logical names are not included in Mermaid output — only name, type, and key marker.

PlantUML

Same scope and limitations as Mermaid (currently-displayed tables, no column comments), but with entity markup: primary-key columns are listed first above a divider, non-nullable columns are marked *, foreign-key columns are marked <<FK>>, and a table's logical name (if it has one) appears alongside the physical name in full-width parentheses.

Excel workbook

--excel FILE.xlsx writes a workbook with no external spreadsheet library involved:

  • Overview sheet — one row per table: #, Table (hyperlinked to that table's own sheet), Comment, Columns (count), Indexes (count), Missing schema.
  • One sheet per table — table name and comment, then a column table (#, Column, Type, Nullable, Default, KeyPK/FK/blank, Extra, Comment), an Indexes section if the table has any (name, columns, unique), and an Associations section if it has any (type, name, target, and how it was sourced: DB FK / inferred / manual / code).

--excel-template FILE.xlsx lets you restyle the workbook — colors, fonts, borders — without touching erdscope's code, via a 5-cell contract: on the template's first worksheet, column A, rows 1 through 5 must be styled as Title / Header / Data / Data (alternate row) / Section respectively. Only each cell's style (font, fill, border) is read — the cell's text content doesn't matter. A missing contract cell falls back to erdscope's built-in style for just that one role, with a warning on stderr; only a file that can't be opened as a .xlsx at all is a hard error.

The repo ships excel-template.xlsx (regenerated by gen_excel_template.py) as a ready-to-edit starting point — its Styles sheet has the five contract cells pre-styled with erdscope's own default look and a plain-language description of each role in column B.

Heads up --excel-template has no effect without --excel — erdscope prints a warning to stderr and otherwise ignores it if you pass a template with no --excel target to style.

Troubleshooting / FAQ #

Japanese (or other multi-byte) comments show up as mojibake

erdscope explicitly connects with utf8mb4, on both the PyMySQL path and the mysql-CLI fallback path — it doesn't rely on the server's or session's default charset. If comments are still garbled after that, the most likely explanation is that the data was already mangled when it was written to the database (a prior import through a non-utf8mb4 connection, for instance) — check the comment directly in the database with a client you know is utf8mb4-correct before assuming it's an erdscope bug.

PyMySQL / psycopg isn't installed — what happens?

erdscope shells out to the database's own CLI instead — mysql for MySQL, psql for PostgreSQL (wrapping queries in COPY … TO STDOUT, whose escaped text format keeps free-text comments from corrupting the output). It must be on PATH; the password is still passed via the MYSQL_PWD / PGPASSWORD environment variable, never as a CLI argument. The two paths produce byte-identical HTML. If neither the driver nor the CLI is available, you get a clear error telling you to install one or the other — erdscope never fails silently here.

Why don't I see any VIEWs in the diagram?

By design — erdscope only reads real, storage-backed tables: TABLE_TYPE = 'BASE TABLE' on MySQL; ordinary and partitioned parent tables (never views or individual partitions) on PostgreSQL, which excludes VIEWs. This keeps the diagram focused on real, storage-backed schema.

Why is --infer-fk off by default?

Because a bare *_id column name is a guess, and guesses can be wrong when nothing backs them — no real association, no DB FK constraint. Turning the flag on doesn't retroactively promote those guesses either: the column-list "FK" badge and the PK/FK column-display mode are grounded strictly in real associations, so an inferred edge always stays visually distinct (a faint dotted line, see Edge types) rather than looking identical to a verified relation.

When do I need --table-map?

It's a Rails-only escape hatch for the rare case where erdscope's static analysis can't work out a model's real table — most commonly, a self.table_name = ... assignment that lives inside a concern module shipped by a gem rather than in the app itself, so there's no app-local source to scan. Pass --table-map 'Widget=crm_widgets' (repeatable, comma-separated lists accepted) to override it explicitly; the override also fixes up any other model's association that points at that model, so right-pane links resolve to the correct table.

A related table isn't showing up even though Auto-expand is on

Auto-expand only pulls in tables reachable by BFS traversal from a checked/focused root, and several things can legitimately stop that traversal short — worth checking in order:

  • The direction filter (Deps/Dependents) may not match the direction the relation actually points.
  • The target table might be banned (🚫) — banned tables are never crossed by Auto-expand, even as a pass-through.
  • The depth limit may be too shallow for that hop count.
  • If you pulled the table in with a node's button, it deliberately doesn't become a new Auto-expand root — it won't cascade further on its own.
  • The table may simply be outside the diagram entirely, if it was excluded at generation time via --only/--exclude.

Extending #

Everything downstream of parsing — the HTML/JS viewer, layouts, and every export format — consumes one intermediate representation (IR), documented in erd.py's own module docstring:

tables = {
  "table_name": {
    "primary_key": "id" | None,
    "comment"?: str,
    "schema_missing"?: bool,        # model exists but no DB table
    "columns": [{"name","type","nullable","primary",
                 "sql_type"?, "default"?, "extra"?, "comment"?}],
    "indexes": [{"name","columns":[...],"unique":bool}],
    "associations": [{"type": has_many|belongs_to|has_one|has_and_belongs_to_many,
                      "name", "target",
                      "through"?, "foreign_key"?, "polymorphic"?,
                      "db_fk"?, "inferred"?}],
  }
}

Adding a new source database means adding one parser that produces this same shape. parse_postgres() is the working example of the pattern: it shapes pg_catalog query results into the exact row layout the MySQL adapter's IR builder consumes, so PK detection, unique-index 1:1 promotion, and index assembly are shared rather than duplicated — the whole adapter is four queries plus a connection layer. The SQL-type-shorthand table already carries names from both engines (character varying, bigserial, jsonb, bytea, inet, ...), so a further engine mostly means mapping its catalog into the same five-column row shapes.