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.
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]':
| Library | Used for | If not installed |
|---|---|---|
| PyMySQL | MySQL connections | Falls back to shelling out to the mysql CLI (must be on PATH) — see Troubleshooting |
| psycopg (or psycopg2) | PostgreSQL connections | Falls back to shelling out to the psql CLI (must be on PATH) — see Troubleshooting |
| PyYAML | Reading a .yml/.yaml config file | A .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:
- Password embedded in the URL (
mysql://user:pass@host/db) — avoid this in practice. - The
MYSQL_PWD(MySQL) /PGPASSWORD(PostgreSQL) environment variable, if set. - Otherwise, if standard input is an interactive terminal, a hidden password prompt
(never touches
argvor shell history).
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]
| Argument | Description |
|---|---|
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 OUTPUT | Output HTML file (default: erd.html) |
--models PATH | Merge 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.xlsx | Also write a table-definition workbook: an overview sheet plus one sheet per table |
--excel-template FILE.xlsx | Override 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 N | Max 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-fk | Guess 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 PATH | Load defaults from a config file. Auto-discovered as .erdscope.json/.yml/.yaml in the current directory if not given |
--no-config | Skip 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.
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:
| Control | Options | Effect |
|---|---|---|
| Depth | 1 / 2 / 3 / ∞ | How many relation hops out from the focused table to include |
| Direction | Both / Deps / Dependents | Deps 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-expand | toolbar checkbox | When 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 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:
| Exclude | Ban (hide) | |
|---|---|---|
| Trigger | Uncheck its box in the list, or the node's ⊖ button | The list row's 🚫 button only |
| Strength | Light — 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 |
| Undo | Re-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.
products banned via the list's 🚫 button — struck through, checkbox locked, and counted in the red banner above.
Search & highlight #
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.
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/⊡ Fitbuttons. - 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 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.
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 directbelongs_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.
| Key | Action |
|---|---|
| Esc | Context-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+Z | Undo the last layout change |
| Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y | Redo |
| 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.
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 2× 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,Key—PK/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.
--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.