<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Son Tran</title>
    <description>The latest articles on DEV Community by Son Tran (@tbson87).</description>
    <link>https://dev.to/tbson87</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F79995%2F6de9d37a-5521-4b7a-a835-9787e74caf0c.jpg</url>
      <title>DEV Community: Son Tran</title>
      <link>https://dev.to/tbson87</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tbson87"/>
    <language>en</language>
    <item>
      <title>You Cannot Tell Which of Your ERD Files Are Under Version Control</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Tue, 04 Aug 2026 17:55:53 +0000</pubDate>
      <link>https://dev.to/tbson87/you-cannot-tell-which-of-your-erd-files-are-under-version-control-2o6c</link>
      <guid>https://dev.to/tbson87/you-cannot-tell-which-of-your-erd-files-are-under-version-control-2o6c</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A diagram stored as a file is only version controlled if its folder happens to sit inside a Git repository, and nothing in the tool tells you whether it does - so the safest-feeling diagrams are often the untracked ones. Schemity now shows a Git branch icon beside every workspace that lives inside a repository, detected by walking up the directory tree so a workspace nested deep inside a project counts too, and marks imported workspaces with their own icon so the ones you deliberately placed inside a repo are distinguishable from the ones the app created for you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A diagram is not under version control because you meant it to be. It is under version control if, and only if, the folder holding it sits inside a Git repository - and until now nothing in Schemity told you which of your workspaces did. Now every workspace that lives inside a repo carries a Git branch icon in the workspace list.&lt;/p&gt;

&lt;p&gt;That sounds like a small thing. It is small the way a fuel gauge is small.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assumption you never actually check
&lt;/h2&gt;

&lt;p&gt;Storing diagrams as plain files is the whole argument for a Git-native ERD tool: text on disk, diffed in pull requests, restored from history. &lt;a href="https://schemity.com/blog/erd-lives-in-your-git-repo/" rel="noopener noreferrer"&gt;The ERD belongs in your Git repo&lt;/a&gt; rather than as a PNG rotting in a wiki. But that argument quietly depends on a fact nobody verifies: that this particular diagram, the one open right now, is inside a repository.&lt;/p&gt;

&lt;p&gt;Six months in, that is genuinely hard to know. You created a workspace for the client project and put it in their repo. You created another for a spike and let it default to &lt;code&gt;~/schemity&lt;/code&gt;. You imported a third from a folder you no longer remember choosing. Every one of them looks identical in the list. They open the same, save the same, and produce the same JSON. The only difference is that three of them are recoverable and one is a file on a laptop.&lt;/p&gt;

&lt;p&gt;This is not a niche confusion, and the proof is that developers have industrialized it. An entire category of command line tools exists purely to answer "which of my folders are Git repositories, and what state are they in" - &lt;a href="https://github.com/nosarthur/gita" rel="noopener noreferrer"&gt;gita, at 1.9k GitHub stars, exists to "manage many git repos with sanity"&lt;/a&gt; by printing their status side by side, and &lt;a href="https://github.com/fboender/multi-git-status" rel="noopener noreferrer"&gt;mgitstatus scans a whole directory tree&lt;/a&gt; looking for &lt;code&gt;.git&lt;/code&gt; dirs "up to DEPTH directories deep" to report uncommitted, untracked and unpushed changes. Nobody writes and maintains a tool for a problem they do not have. Developers lose track of which folders are tracked, routinely enough to build instruments for it.&lt;/p&gt;

&lt;p&gt;The failure mode is specific and quiet. Nothing breaks. No error appears. The diagram saves perfectly every time. You find out the day you need the version from before the refactor, and there is no history to go back to.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I know which of my database diagrams are under version control?
&lt;/h2&gt;

&lt;p&gt;Look at the workspace list. Any workspace whose folder resolves to somewhere inside a Git repository shows a branch icon beside its name.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnbo6s3z4ytcqo7jmmhd4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnbo6s3z4ytcqo7jmmhd4.webp" alt="The Schemity Connection Manager workspace list: demo, default and work each carry a Git branch icon, erd shows only the imported-workspace icon, and suberd carries both icons" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five workspaces, four different situations, readable in one glance. &lt;code&gt;demo&lt;/code&gt;, &lt;code&gt;default&lt;/code&gt; and &lt;code&gt;work&lt;/code&gt; sit inside repositories. &lt;code&gt;erd&lt;/code&gt; was imported from elsewhere on the machine but is not in one, so it is a single copy on this laptop. &lt;code&gt;suberd&lt;/code&gt; was imported &lt;em&gt;and&lt;/em&gt; is inside a repository, which is the arrangement you want for a diagram that belongs to a project.&lt;/p&gt;

&lt;p&gt;There is no configuration and no connection step. Schemity checks the workspace folder's location on disk and reports what it finds. A workspace under version control looks different from one that is not, at the moment you are choosing which to open, which is the only moment the information is useful. The check is about the folder, not about your commits - the icon tells you the diagram &lt;em&gt;can&lt;/em&gt; be tracked, not that you have remembered to commit it.&lt;/p&gt;

&lt;p&gt;This closes a real gap in the &lt;a href="https://schemity.com/doc/version-control-git/" rel="noopener noreferrer"&gt;version control workflow&lt;/a&gt;. The &lt;a href="https://schemity.com/doc/json-storage-format/" rel="noopener noreferrer"&gt;JSON storage format&lt;/a&gt; made diagrams diffable, and &lt;a href="https://schemity.com/doc/create-a-workspace/" rel="noopener noreferrer"&gt;creating a workspace&lt;/a&gt; put them in a folder you control. What was missing was any way to confirm the arrangement had actually happened for a given workspace, rather than being something you intended in March.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the two workspace icons mean
&lt;/h2&gt;

&lt;p&gt;Workspaces now carry up to two independent markers, and reading them together tells you where a diagram lives and whether it is safe:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Icons shown&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Recoverable from history?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Created by Schemity in the default &lt;code&gt;~/schemity&lt;/code&gt; folder, not inside any repository&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git branch&lt;/td&gt;
&lt;td&gt;The folder sits inside a Git repository&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Imported&lt;/td&gt;
&lt;td&gt;Stored somewhere else on your machine, outside &lt;code&gt;~/schemity&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not by itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Imported + Git branch&lt;/td&gt;
&lt;td&gt;Deliberately placed inside a project's own repository&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the one worth aiming for. It is the arrangement where the ERD travels with the code it describes: same repo, same branch, same pull request, same history. A schema change and the diagram change land in one commit, and a reviewer sees both.&lt;/p&gt;

&lt;p&gt;The first row is the one worth auditing. A workspace with no icons is not wrong - a scratch diagram belongs nowhere near a repo - but it should be a choice rather than a discovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why detection has to walk up the directory tree
&lt;/h2&gt;

&lt;p&gt;The obvious implementation checks whether the workspace folder is a repository root. That would be wrong most of the time, because almost nobody puts an ERD at the top level of a project.&lt;/p&gt;

&lt;p&gt;A diagram lives in &lt;code&gt;docs/schema/&lt;/code&gt;, or &lt;code&gt;db/erd/&lt;/code&gt;, or &lt;code&gt;design/&lt;/code&gt;. It sits several levels down inside a repository whose root is somewhere above it. So Schemity walks up the directory tree from the workspace folder and reports a match wherever it finds the repository, however deep the workspace is nested. A workspace at &lt;code&gt;~/work/acme-api/docs/database/&lt;/code&gt; is correctly marked as tracked because &lt;code&gt;~/work/acme-api/&lt;/code&gt; is the repo.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds, because the root-only version of this feature would mislabel exactly the workspaces that are set up correctly. The engineer who did the right thing - filed the diagram in a sensible subdirectory of the project - would see no icon, and the feature would be worse than not having it. A signal that is wrong in the well-organized case teaches you to ignore the signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point is the audit, not the icon
&lt;/h2&gt;

&lt;p&gt;Open the workspace list and read it once. Every workspace with a branch icon is one you can recover, review, and blame. Every workspace without one is &lt;a href="https://schemity.com/blog/your-erd-shouldnt-be-able-to-just-disappear/" rel="noopener noreferrer"&gt;a single copy of a design that can simply disappear&lt;/a&gt;, and you now know which ones those are rather than assuming the answer.&lt;/p&gt;

&lt;p&gt;For the ones that should be tracked and are not, the fix is the same as it has always been: &lt;a href="https://schemity.com/doc/quick-start/" rel="noopener noreferrer"&gt;keep the workspace inside a Git repository&lt;/a&gt; by moving its folder into the repo where it belongs, or import a fresh workspace from a folder inside the project. What changes is that you can now tell there is something to fix.&lt;/p&gt;

&lt;p&gt;That is the whole feature. &lt;a href="https://schemity.com/blog/erd-tool-built-for-software-engineers/" rel="noopener noreferrer"&gt;An ERD tool built for software engineers&lt;/a&gt; should not ask you to take version control on faith, any more than your editor asks you to guess which branch you are on.&lt;/p&gt;

</description>
      <category>database</category>
      <category>git</category>
      <category>sql</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Why Schemity Has No Zoom: Fuzzy Search and a Minimap Instead</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Tue, 04 Aug 2026 17:54:57 +0000</pubDate>
      <link>https://dev.to/tbson87/why-schemity-has-no-zoom-fuzzy-search-and-a-minimap-instead-3e1c</link>
      <guid>https://dev.to/tbson87/why-schemity-has-no-zoom-fuzzy-search-and-a-minimap-instead-3e1c</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Zoom looks free but is not: each zoom changes the diagram's geography so you have to rebuild your sense of where things are, it fights the mouse wheel you need for scrolling, and it leaves you managing a zoom percentage you now have to reset. Schemity has no zoom control at all. The jobs zoom was doing - finding a distant entity, spotting tables a migration added outside the app, seeing where imported entities landed, and getting a first overview of a reverse-engineered schema - are handled by realtime fuzzy search and a minimap that shows where things are without pretending to be a smaller copy of the diagram.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Schemity has no zoom control, and that is a decision rather than an omission. Zoom is one of those features that looks free because every canvas app ships it, but it charges the designer on every single use, and once the jobs it was doing are handled properly there is nothing left for it to pay for.&lt;/p&gt;

&lt;p&gt;The question we asked was not "should we add zoom?" It was: what problems does zoom solve, and what problems does it introduce?&lt;/p&gt;

&lt;h2&gt;
  
  
  What zoom actually costs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It resets your spatial memory.&lt;/strong&gt; A large ERD is something you navigate by remembering where things are. Orders live down and to the right, identity is up near the top left, the audit tables hang off the bottom. That memory is the thing that makes a big diagram workable at all. Every zoom changes the geography: distances between entities change, what is on screen changes, the shape you had memorized is replaced by a different one. You then spend a few seconds re-establishing where you are. Individually trivial, and paid every single time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It fights the input it needs.&lt;/strong&gt; The default binding for zoom on a canvas is the mouse wheel, and the mouse wheel is what you want for scrolling. So a tool either takes the wheel away from scrolling, or it puts zoom behind a modifier - space, control, command plus wheel. Panning around a schema is a constant, high-frequency action. Putting a modifier on either half of that pair taxes something you do hundreds of times an hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It creates state you have to manage.&lt;/strong&gt; Once zoom exists, the diagram has a zoom percentage, and the percentage becomes something you track. Am I at 80% or 60%? Why does this entity look smaller than it did yesterday? How do I get back to 100%? That is a new category of question that had nothing to do with designing a database.&lt;/p&gt;

&lt;p&gt;None of this is peculiar to ERDs. It is a known cost of infinite canvases, and even the tools built entirely around zoom run into its edges. A Figma user &lt;a href="https://forum.figma.com/report-a-problem-6/zoom-out-limit-54469" rel="noopener noreferrer"&gt;asked the forum why they could no longer zoom out to see all their content&lt;/a&gt; - "is there any reason why i now can not zoom out to see all the content?" - because they wanted to overview the file before zooming into sections. The thread, 105 views and 3 replies, ended with the answer that the file had grown to 100,000px wide and hit Figma's zoom limit. The overview they wanted was not available at any zoom level. Muse's 2020 memo on the infinite canvas made the sharper version of the point: arbitrary zooming lets you pull out to see the landscape and pull back in for detail work, "but this adds another layer of confusion because there is no consistent sense of scale."&lt;/p&gt;

&lt;h2&gt;
  
  
  What zoom was supposed to solve
&lt;/h2&gt;

&lt;p&gt;The costs only matter if the benefits can be delivered another way, so here are the real jobs, stated fairly. On a large ERD, designers zoom out because they need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reach an area far outside the viewport.&lt;/strong&gt; The entity you want is somewhere off screen and panning to it is slow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spot tables a migration added outside the app.&lt;/strong&gt; You ran an ORM migration, new tables now exist, and after a re-sync they are placed somewhere on a large canvas. You do not know where.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find entities you just imported into a context view.&lt;/strong&gt; They landed somewhere and now need arranging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get a first overview of a reverse-engineered database.&lt;/strong&gt; A freshly imported schema arrives in a default layout, and before arranging anything you need to see the whole shape to reason about it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep track of new entities you created in several places.&lt;/strong&gt; You have been working across the diagram and want to see what you added.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those is a genuine need. Notice what they have in common: not one of them is a request to make the diagram smaller. They are all requests to &lt;em&gt;locate&lt;/em&gt; something. Zoom is a way of answering a location question by shrinking everything until the answer happens to be on screen, which is a very indirect way to answer it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I navigate a large ER diagram without zooming out?
&lt;/h2&gt;

&lt;p&gt;Two features, split by whether you know what you are looking for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you know the name, search.&lt;/strong&gt; &lt;a href="https://schemity.com/blog/finding-a-column-shouldnt-take-a-sql-query/" rel="noopener noreferrer"&gt;Realtime fuzzy search&lt;/a&gt; across entity and field names takes whatever fragment you remember - not necessarily the start, not necessarily contiguous - and updates as you type.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp0qqjh45czsz5vt2pue0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp0qqjh45czsz5vt2pue0.webp" alt="Schemity's search panel over a 59-entity schema: the query cndtio matches the filter_conditions entity plus the filter_condition_id fields in filter_items, segment_members, and segments_filter_conditions" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Typing &lt;code&gt;cndtio&lt;/code&gt; into a 59-entity schema returns the &lt;code&gt;filter_conditions&lt;/code&gt; entity and every &lt;code&gt;filter_condition_id&lt;/code&gt; field that references it, across three different tables - even though not one of those names contains the literal string &lt;code&gt;cndtio&lt;/code&gt;. Arrow down, hit enter, and the viewport is on it. That is the "reach a distant area" job, done in a keystroke instead of a zoom-pan-zoom cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you do not know the name, use the minimap.&lt;/strong&gt; A toggleable minimap sits at the bottom right of &lt;a href="https://schemity.com/doc/erd-canvas/" rel="noopener noreferrer"&gt;the canvas&lt;/a&gt; showing every legend and entity in miniature, with a rectangle marking the part the viewport currently covers. Click anywhere on it to jump there, or drag the rectangle to pan continuously. The main view and each context view carry their own minimap, so switching views switches the map with it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4bx3qwbkmrlurkkg587d.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4bx3qwbkmrlurkkg587d.webp" alt="The same schema after a migration added a table: new_entities is drawn with a dashed border on the canvas, and appears as an orange block on the minimap at the bottom right, with the blue viewport rectangle showing the visible region" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And for the three jobs that are really "where did the new things land?", the minimap answers directly. An entity the database has not confirmed yet is drawn with a dashed border on the canvas, and on the minimap it is drawn in orange - the one case where the minimap uses color at all. In the schema above, the entity count has gone from 59 to 60 and the single orange block tells you exactly where the new table went. So when &lt;a href="https://schemity.com/doc/resync-database/" rel="noopener noreferrer"&gt;a re-sync pulls new tables in from the live database&lt;/a&gt; after an out-of-app migration, or when entities are imported into &lt;a href="https://schemity.com/doc/context-views/" rel="noopener noreferrer"&gt;a context view&lt;/a&gt;, you can see where they landed without hunting for them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The job&lt;/th&gt;
&lt;th&gt;What zoom does&lt;/th&gt;
&lt;th&gt;What Schemity does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reach a distant entity you can name&lt;/td&gt;
&lt;td&gt;Zoom out, find it, zoom back in&lt;/td&gt;
&lt;td&gt;Fuzzy search, one keystroke to the entity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Find tables a migration added&lt;/td&gt;
&lt;td&gt;Zoom out and scan for unfamiliar shapes&lt;/td&gt;
&lt;td&gt;Dashed entities visible on the minimap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Locate entities imported into a context view&lt;/td&gt;
&lt;td&gt;Zoom out and scan&lt;/td&gt;
&lt;td&gt;Dashed on the minimap, exactly where they landed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Get a first overview of a reverse-engineered schema&lt;/td&gt;
&lt;td&gt;Zoom out until it fits, if it fits&lt;/td&gt;
&lt;td&gt;Minimap shows the whole shape at a fixed size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Move to another part of the diagram&lt;/td&gt;
&lt;td&gt;Zoom out, pan, zoom in&lt;/td&gt;
&lt;td&gt;Click the minimap, or drag its viewport rectangle&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why the minimap deliberately shows less
&lt;/h2&gt;

&lt;p&gt;The obvious way to build a minimap is to render the diagram smaller. That reproduces the exact problem zoom has, because a shrunken copy of a dense ERD is an unreadable smudge.&lt;/p&gt;

&lt;p&gt;So the minimap is not a small diagram. It draws only legends and entities, never relationships, and it ignores the custom colors you assigned to legends and entities on the canvas. It is a map of &lt;em&gt;where things are&lt;/em&gt;, not a second copy of the diagram, which is why it stays readable no matter how dense or how colorful the ERD gets. The orange draft marker is the deliberate exception: the map spends its one color on the single question you cannot answer by looking, which is where the new thing went. Dropping the relationship lines is what keeps it legible at minimap size, and the information you lose is information you were not going to be able to read anyway.&lt;/p&gt;

&lt;p&gt;That is the whole trade. Zoom offers a continuous range of scales, all of which change the geography and none of which is guaranteed to answer your question. A fixed-size map plus a name search answers the question directly and leaves the diagram exactly where you left it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general principle
&lt;/h2&gt;

&lt;p&gt;Every feature that ships has to justify what it costs the people using it, and interaction costs are the easiest kind to overlook because each instance is small. A feature used a hundred times a day at two seconds of re-orientation is not a small feature.&lt;/p&gt;

&lt;p&gt;The useful discipline is to write down the jobs a feature is really being asked to do, then ask whether something more direct can do them. For zoom on an ERD, the jobs were all location questions, and location questions have better answers than scale changes. That is also why &lt;a href="https://schemity.com/blog/you-dont-need-a-diagram-of-all-800-tables/" rel="noopener noreferrer"&gt;you do not need a diagram of all 800 tables&lt;/a&gt; in the first place: the instinct to see everything at once is usually a navigation problem wearing a layout costume.&lt;/p&gt;

</description>
      <category>database</category>
      <category>ux</category>
      <category>design</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Database Views in Your ERD: Read-Only Entities, Not Fake Tables</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Sun, 02 Aug 2026 03:02:00 +0000</pubDate>
      <link>https://dev.to/tbson87/database-views-in-your-erd-read-only-entities-not-fake-tables-27l3</link>
      <guid>https://dev.to/tbson87/database-views-in-your-erd-read-only-entities-not-fake-tables-27l3</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Database views carry real responsibilities - reporting layers, security boundaries, API surfaces - but ERD tools either leave them out entirely (DBML has no view support despite requests since 2022) or draw them as if they were ordinary tables. Schemity displays views and materialized views as read-only entities with italic names and a bold view or mview token in the entity footer, so derived relations are distinguishable from base tables at a glance, and they can be imported into context views like any other entity.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A database view belongs in your ERD, but not disguised as a table: it is a derived, read-only relation, and the diagram should say so at a glance. Schemity draws views and materialized views as read-only entities with italic names - present on the canvas, visually distinct from the base tables they are built on.&lt;/p&gt;

&lt;p&gt;That sentence would be unremarkable if the rest of the tooling world agreed with it. Mostly, it does not. In most ERD tools your views are simply absent, and in the rest they are dressed up as something they are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reporting layer your diagram pretends does not exist
&lt;/h2&gt;

&lt;p&gt;Views are not decoration. They are where schemas put their public face: the reporting layer that joins five tables into one readable relation, the security boundary that exposes a subset of columns to an application role, the compatibility shim that survives a refactor. On &lt;a href="https://schemity.com/blog/supabase-schema-diagram/" rel="noopener noreferrer"&gt;Supabase&lt;/a&gt;, views are how you shape what PostgREST exposes as an API. A materialized view may be the single most performance-critical object in an analytics schema. Whoever reads your diagram to understand the system needs to see them.&lt;/p&gt;

&lt;p&gt;Yet the diagram usually cannot show them. DBML - the schema language behind dbdiagram.io - has no syntax for views at all: a user proposed &lt;a href="https://community.dbdiagram.io/t/desiging-and-creating-views/1822" rel="noopener noreferrer"&gt;designing views with join definitions&lt;/a&gt; in December 2022, others were still upvoting the request in July 2024, and there has been no official reply. SSMS database diagrams accept tables only. And &lt;a href="https://schemity.com/blog/schemity-vs-chartdb/" rel="noopener noreferrer"&gt;ChartDB&lt;/a&gt;, the most active cloud schema visualizer, shipped view support only in v1.15 in August 2025 - a year into the product's life - having earlier demonstrated the opposite failure mode: before views were modeled properly, &lt;a href="https://github.com/chartdb/chartdb/issues/14" rel="noopener noreferrer"&gt;you could draw a foreign key pointing into a view&lt;/a&gt;, which is exactly the kind of nonsense a diagram produces when it cannot tell a derived relation from a base table.&lt;/p&gt;

&lt;p&gt;So the designer documenting a real schema gets a choice: leave the views out and hand readers a diagram that hides the reporting layer, or smuggle them in as fake tables and hand readers a diagram that lies about what is editable.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Database views in the diagram&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;dbdiagram.io (DBML)&lt;/td&gt;
&lt;td&gt;Not supported - requested since Dec 2022, no official reply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSMS database diagrams&lt;/td&gt;
&lt;td&gt;Tables only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChartDB&lt;/td&gt;
&lt;td&gt;Added Aug 2025 (v1.15); earlier versions allowed a foreign key into a view&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL Workbench&lt;/td&gt;
&lt;td&gt;Supported in EER models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schemity&lt;/td&gt;
&lt;td&gt;Read-only entities with italic names; importable into context views&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A view drawn like a table is a small lie
&lt;/h2&gt;

&lt;p&gt;The absence is bad; the disguise is worse. A view rendered as an ordinary table invites every wrong conclusion a reader can draw. It implies you could add a column to it the way you would to a table. It implies a foreign key could land on it. It implies its rows live somewhere, when they are a query executed on demand. Materialized views sharpen the trap: they do hold physical rows and can carry indexes, so they look even more like tables - but their columns are still derived, and the way to change one is to change its defining query, not to edit fields in a dialog.&lt;/p&gt;

&lt;p&gt;The diagram should encode that difference, not rely on a naming convention (&lt;code&gt;v_&lt;/code&gt; prefixes are a workaround, not a design) or on the reader's memory of which boxes are real. Schemity uses three signals: views and materialized views are read-only on the canvas, so there is no field editing to be misled by; their names render in italics; and the entity footer says it outright, opening with a bold &lt;code&gt;view&lt;/code&gt; or &lt;code&gt;mview&lt;/code&gt; token ahead of the usual field and index counts, so nobody is left inferring the kind of relation from a naming convention. The distinction survives at any zoom level and in any export.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn1t0cnrd7e8wgy9yu4of.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn1t0cnrd7e8wgy9yu4of.webp" alt="A materialized view and a view in Schemity, side by side: italic entity names, and footers reading mview, field: 3, idx: 1, u: 1 and view, field: 3 - the mview carrying an index and a unique marker no plain view can have" width="800" height="705"&gt;&lt;/a&gt; The same philosophy as the bold crow's foot for &lt;a href="https://schemity.com/blog/on-delete-cascade-is-invisible-in-your-erd/" rel="noopener noreferrer"&gt;ON DELETE CASCADE&lt;/a&gt;: a structural fact about the schema deserves a visual channel of its own, one that cannot go stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I show database views in an ERD?
&lt;/h2&gt;

&lt;p&gt;Open a diagram backed by your database in Schemity - version 2.9.0 or later - and the views are on the canvas alongside the tables - a desktop ERD tool &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;reading the schema from the live database&lt;/a&gt; rather than from a hand-maintained model, so the derived layer is documented from the same source of truth as everything else. You arrange them, color them, and group them under legends exactly like base tables; you just cannot edit what is not editable.&lt;/p&gt;

&lt;p&gt;The naming collision is worth untangling here, because Schemity also has a feature called context views, and the two are unrelated the way &lt;code&gt;database view&lt;/code&gt; and &lt;code&gt;point of view&lt;/code&gt; are unrelated. A context view is a focused sub-diagram that displays a chosen subset of the main diagram while the main view remains the single source of truth. Database views - the SQL kind - are entities &lt;em&gt;inside&lt;/em&gt; your diagrams, and they &lt;a href="https://schemity.com/doc/context-views/" rel="noopener noreferrer"&gt;can be imported into a context view&lt;/a&gt; like any other entity.&lt;/p&gt;

&lt;p&gt;That combination is where the feature earns its keep. The reporting layer is usually its own bounded concern: a handful of views over a dozen base tables, consumed by dashboards and exports. Pull those views and their source tables into one context view and you have the reporting layer as a diagram - what feeds what, which base tables are load-bearing, where a refactor will ripple. The same works for a security-boundary story: one context view showing exactly which relations an application role can see, &lt;a href="https://schemity.com/blog/the-data-dictionary-should-live-in-the-erd/" rel="noopener noreferrer"&gt;readable by the auditor who asked&lt;/a&gt; without a single query.&lt;/p&gt;

&lt;p&gt;Views exist because base tables alone do not tell the whole story of a schema. A diagram that omits them - or forges them - tells even less of it. Put them on the canvas, in italics, where they belong.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>postgres</category>
      <category>architecture</category>
    </item>
    <item>
      <title>SSMS Database Diagrams: Your ERD Is Trapped Inside the Database It Documents</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Sun, 02 Aug 2026 03:01:21 +0000</pubDate>
      <link>https://dev.to/tbson87/ssms-database-diagrams-your-erd-is-trapped-inside-the-database-it-documents-49i1</link>
      <guid>https://dev.to/tbson87/ssms-database-diagrams-your-erd-is-trapped-inside-the-database-it-documents-49i1</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; SSMS keeps every database diagram as a binary blob in the sysdiagrams system table of the database it describes, so it cannot be saved to a file, versioned in Git, or opened without a server connection - and the feature itself was removed in SSMS 18.0 and restored in 18.1 only after user outcry. Schemity, a desktop ERD tool with SQL Server support, reverse engineers the same schema into a plain local JSON file that diffs in pull requests, exports to SVG and Mermaid, and re-syncs from the live database without losing your layout.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The database diagram you built in SSMS is not a file - it is a row of binary data in a system table called &lt;code&gt;sysdiagrams&lt;/code&gt;, inside the very database it documents. That is why you cannot attach it to a design doc, review it in a pull request, or open it after the server is gone. Getting a SQL Server ERD you can actually keep means reverse engineering the schema into a tool that treats the diagram as a document you own.&lt;/p&gt;

&lt;p&gt;Most people discover this the day they try to share the diagram. You spent an hour dragging tables into place, the picture finally explains the schema, and a teammate asks for a copy. There is no Save As. One user asked Microsoft directly how to save an SSMS ER diagram to a file so the team could open it, and the accepted workarounds were &lt;a href="https://learn.microsoft.com/en-us/answers/questions/971131/er-diagram-to-file-for-ssms" rel="noopener noreferrer"&gt;copy it to the clipboard as an image, or print it to PDF&lt;/a&gt; - a screenshot with extra steps. The diagram itself stays where it was born: inside the database, invisible to Git, unreachable without a connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  A diagram stored as a binary row cannot be shared, diffed, or kept
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sysdiagrams&lt;/code&gt; has a &lt;code&gt;definition&lt;/code&gt; column of type &lt;code&gt;varbinary(max)&lt;/code&gt;, and your entire diagram - every table position, every annotation - is one opaque value in it. Every consequence follows from that storage decision. Copy the database with a script and the diagram is gone, because schema scripts do not carry system-table contents. Restore an old backup and you get the old diagram, with no way to see what changed. Two DBAs cannot work on the picture without overwriting each other. And nothing about the diagram can be reviewed, because there is nothing readable to review.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsjxmrudk9owcivsm5xq6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsjxmrudk9owcivsm5xq6.webp" alt="The SSMS database diagram designer open on AdventureWorks2012: an unsaved Diagram_0 tab, the Database Diagrams node in Object Explorer, and table boxes with key icons - on save, all of this becomes one binary row in sysdiagrams, not a file" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The feature's own history says how much weight it can bear. Microsoft removed database diagrams outright in SSMS 18.0 in 2019, brought them back in 18.1 after users pushed back, and only fixed the lingering breakage in 18.6. Even then the designer stayed coupled to the tooling version: connect SSMS 18.12.1 to SQL Server 2022 and it refuses with &lt;a href="https://learn.microsoft.com/en-us/answers/questions/1109519/ssms-18-gives-error-this-backend-version-is-not-su" rel="noopener noreferrer"&gt;"This backend version is not supported to design database diagrams or tables"&lt;/a&gt; until you upgrade SSMS again. A diagram you cannot export, produced by a designer that has been removed once and version-locked ever since, is not documentation. It is a screensaver for one machine.&lt;/p&gt;

&lt;p&gt;There is one more trap worth naming: the SSMS diagram designer doubles as a table designer. Saving a diagram can save table changes with it, so an absent-minded edit inside what you thought was a picture becomes a real ALTER against the database. A diagram should never have that power by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I export a database diagram from SSMS to a file?
&lt;/h2&gt;

&lt;p&gt;The honest answer is that you cannot - not as a diagram. The clipboard-image and print-to-PDF workarounds produce pictures that no tool can reopen, and the deeper trick of copying &lt;code&gt;sysdiagrams&lt;/code&gt; rows between databases just relocates the blob without ever making it readable. Whatever you produce is frozen at export time and dead to version control.&lt;/p&gt;

&lt;p&gt;So the useful move is to stop trying to get the picture out and instead get the schema out - which SQL Server hands to any client that asks the catalog. A desktop ERD tool that speaks SQL Server can &lt;a href="https://schemity.com/doc/connect-sql-server/" rel="noopener noreferrer"&gt;connect to the database&lt;/a&gt; and &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;reverse engineer the whole schema into a diagram&lt;/a&gt; in one step: tables, foreign keys, unique constraints, the lot. Schemity is built for exactly this - an offline, local ERD tool where SQL Server sits alongside PostgreSQL, MySQL, MariaDB, and SQLite as a first-class connection, over a direct connection or an SSH tunnel.&lt;/p&gt;

&lt;p&gt;The difference is not the drawing. It is where the drawing lives.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;SSMS database diagram&lt;/th&gt;
&lt;th&gt;Schemity diagram&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stored as&lt;/td&gt;
&lt;td&gt;Binary row in &lt;code&gt;sysdiagrams&lt;/code&gt;, inside the database&lt;/td&gt;
&lt;td&gt;Plain &lt;a href="https://schemity.com/doc/json-storage-format/" rel="noopener noreferrer"&gt;JSON file in a local folder&lt;/a&gt; you choose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version control&lt;/td&gt;
&lt;td&gt;No - binary blob, nothing to diff&lt;/td&gt;
&lt;td&gt;Yes - line-by-line diffs in Git and pull requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open without the server&lt;/td&gt;
&lt;td&gt;No - requires a live connection&lt;/td&gt;
&lt;td&gt;Yes - it is a local file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survives tooling upgrades&lt;/td&gt;
&lt;td&gt;Designer version-locked to the engine&lt;/td&gt;
&lt;td&gt;File format independent of any server version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export&lt;/td&gt;
&lt;td&gt;Clipboard image or print to PDF&lt;/td&gt;
&lt;td&gt;SVG, PNG, JPG, Mermaid, SQL DDL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema changes&lt;/td&gt;
&lt;td&gt;Saving the diagram can silently alter tables&lt;/td&gt;
&lt;td&gt;Nothing applies without a reviewed &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL diff&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The diagram becomes a file: local JSON, versioned in Git
&lt;/h2&gt;

&lt;p&gt;When Schemity reverse engineers your SQL Server database, the result is a plain JSON file in a workspace folder on your machine - ERD tool JSON storage in the most literal sense. Put that folder in a repository and you have a Git-native ERD: the afternoon you spend arranging tables into domains is committed, branched, and recoverable, and a schema change shows up in review as readable lines instead of an unexplained binary. This is how you &lt;a href="https://schemity.com/doc/version-control-git/" rel="noopener noreferrer"&gt;version control a database diagram in Git&lt;/a&gt; without fighting the format.&lt;/p&gt;

&lt;p&gt;Staying current stops being a rewrite, too. &lt;a href="https://schemity.com/doc/resync-database/" rel="noopener noreferrer"&gt;Re-sync pulls the latest schema from the live database&lt;/a&gt; every time you reopen the diagram - the live database stays the source of truth, new tables arrive ready to place, dropped ones vanish, and every entity you already arranged keeps its position. The layout work survives, which is precisely the work &lt;a href="https://schemity.com/blog/the-schema-diagram-your-dashboard-wont-let-you-keep/" rel="noopener noreferrer"&gt;dashboard-style visualizers throw away&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And sharing finally works the way documents work. A teammate opens the diagram file in read-only mode with no access to the connection behind it. The picture leaves as &lt;a href="https://schemity.com/blog/why-your-erd-export-turns-blurry/" rel="noopener noreferrer"&gt;a sharp SVG instead of a blurry clipboard bitmap&lt;/a&gt;, or as a Mermaid file that GitHub and Notion render natively, or as the SQL DDL itself when that is the real question. None of it requires the reader to have SSMS, credentials, or a matching tooling version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documentation should not live inside the thing it documents
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;sysdiagrams&lt;/code&gt; design gets one thing exactly backwards: it makes the documentation depend on the system being documented. If the server is down, migrated, restored, or retired, the explanation of the schema goes with it - which is the moment you need the explanation most. The same failure wears other costumes: &lt;a href="https://schemity.com/blog/your-erd-shouldnt-be-able-to-just-disappear/" rel="noopener noreferrer"&gt;cloud tools that can lose a diagram outright&lt;/a&gt; because it is a row in someone else's database, and dashboards that regenerate the picture from scratch on every visit. The cure is the same each time - make the diagram &lt;a href="https://schemity.com/blog/documenting-production-shouldnt-feel-dangerous/" rel="noopener noreferrer"&gt;a local file that outlives every server and every tool version&lt;/a&gt;, sitting in your repo next to the code that uses the schema.&lt;/p&gt;

&lt;p&gt;Your SQL Server schema deserves a diagram with a path, not a diagram with a connection string. Take it out of the database.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sqlserver</category>
      <category>sql</category>
      <category>documentation</category>
    </item>
    <item>
      <title>Supabase Schema Diagram: Get an ERD You Can Keep, Not a Dashboard View</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Sat, 01 Aug 2026 11:32:04 +0000</pubDate>
      <link>https://dev.to/tbson87/supabase-schema-diagram-get-an-erd-you-can-keep-not-a-dashboard-view-2hdd</link>
      <guid>https://dev.to/tbson87/supabase-schema-diagram-get-an-erd-you-can-keep-not-a-dashboard-view-2hdd</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The Schema Visualizer built into Supabase Studio is a viewer, not a document - it renders one schema at a time, saves your dragged layout only to that browser's localStorage, and exports at most a flat image. The durable way to get a Supabase schema diagram is to connect an ERD tool to the underlying Postgres over the session-mode connection string (port 5432) and reverse engineer it. Schemity treats Supabase as a first-class connection, pulls the schema with every foreign key and constraint intact, keeps your layout across re-syncs, and stores the diagram as a plain JSON file in Git.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To get a Supabase schema diagram you can keep, connect an ERD tool to the project's underlying Postgres database - session mode, port 5432 - and reverse engineer it. The Schema Visualizer inside Supabase Studio will not get you there: it is a viewer bolted onto the dashboard - the layout you make lives in one browser, and the most it can hand you is a flat image.&lt;/p&gt;

&lt;p&gt;That distinction takes about a week of real use to feel. The visualizer demos beautifully - open the dashboard, click Database, click Schema Visualizer, and your tables appear with lines between them. Then you try to use it as documentation and the seams show.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the built-in Schema Visualizer actually gives you
&lt;/h2&gt;

&lt;p&gt;The Schema Visualizer arrived in Supabase Studio 3.0 as an answer to one of the platform's oldest feature requests, and it is genuinely useful for a first glance. But three limits surface quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It shows one schema at a time.&lt;/strong&gt; A Supabase project is never just &lt;code&gt;public&lt;/code&gt; - there are &lt;code&gt;auth&lt;/code&gt;, &lt;code&gt;storage&lt;/code&gt;, and &lt;code&gt;graphql_public&lt;/code&gt; at minimum, and the visualizer renders each in isolation. The picture of how your application tables relate to the platform's is a picture nobody can draw in the dashboard. The gap is real enough that a community member &lt;a href="https://dev.to/idevbrandon/i-built-supabase-markdown-a-tool-to-generate-a-full-supabase-erd-across-all-schemas-because-21li"&gt;built a separate tool to generate an ERD across all schemas&lt;/a&gt; - the "because Visualizer can't" is in the project's own tagline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The layout lives in your browser, not in the project.&lt;/strong&gt; Early versions &lt;a href="https://github.com/supabase/supabase/issues/16402" rel="noopener noreferrer"&gt;opened vertically stretched and dropped your repositioning entirely&lt;/a&gt;; Supabase has since fixed the persistence half, so a dragged table now keeps its position across refreshes. But it keeps it in exactly one place: that browser's localStorage, keyed to the project and schema. Nothing syncs to the project itself - a teammate opening the same visualizer gets the auto-layout, a new laptop starts from zero, clearing site data erases the arrangement, and the relationship lines cannot be reshaped at all. An arrangement only one browser can see is a preference, not documentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What leaves is a picture, not a document.&lt;/strong&gt; The visualizer can download the current view as an SVG or PNG, and each table's three-dot menu will copy that single table's definition as SQL or Markdown - genuinely handy for a quick paste. What it cannot produce is a diagram document: a file another tool can reopen, a layout a teammate inherits, anything Git can diff in a pull request. Even the SVG is not what the extension promises - it is the dashboard's HTML wrapped in a single &lt;code&gt;foreignObject&lt;/code&gt; tag with the browser's computed styles inlined on every element. Exporting the same database both ways: 6.4 MB from the visualizer, with no SVG text elements and blank output in vector editors, against &lt;a href="https://schemity.com/blog/why-your-erd-export-turns-blurry/" rel="noopener noreferrer"&gt;99 KB of true vector from Schemity&lt;/a&gt;. The export is a flat image of one schema, frozen at the moment you clicked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fap2722ixokgkksw60fle.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fap2722ixokgkksw60fle.png" alt="PNG export from Supabase Studio's Schema Visualizer: plain table boxes for the public schema with column names and types, straight auto-routed lines, no constraint detail" width="800" height="535"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of this is a flaw in Supabase - the dashboard's job is administering the project, not holding your data model. The mistake is asking a dashboard widget to be &lt;a href="https://schemity.com/blog/the-schema-diagram-your-dashboard-wont-let-you-keep/" rel="noopener noreferrer"&gt;the schema diagram your team keeps&lt;/a&gt;, a job it was never built for.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I get an ER diagram of my Supabase database?
&lt;/h2&gt;

&lt;p&gt;Underneath every Supabase project is a plain PostgreSQL database, and that is the door to walk through. Any tool that can reverse engineer Postgres can diagram a Supabase project - the only Supabase-specific part is picking the right connection string.&lt;/p&gt;

&lt;p&gt;Open Connect in the dashboard and you will find three strings. They are not interchangeable for a schema tool:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Connection&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Built for&lt;/th&gt;
&lt;th&gt;Works for an ERD tool?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct connection&lt;/td&gt;
&lt;td&gt;5432&lt;/td&gt;
&lt;td&gt;Long-lived servers&lt;/td&gt;
&lt;td&gt;Yes, but resolves to IPv6 unless you have the IPv4 add-on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session pooler (Supavisor)&lt;/td&gt;
&lt;td&gt;5432&lt;/td&gt;
&lt;td&gt;Long-lived connections over IPv4&lt;/td&gt;
&lt;td&gt;Yes - the safe default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transaction pooler&lt;/td&gt;
&lt;td&gt;6543&lt;/td&gt;
&lt;td&gt;Serverless / edge functions&lt;/td&gt;
&lt;td&gt;No - connections are reclaimed after each query and session features are restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The session-mode string on port 5432 is the one to use: a schema tool wants exactly one ordinary, long-lived Postgres connection, and session mode provides it over IPv4 from anywhere. The transaction pooler on 6543 exists for a different problem - thousands of short-lived serverless invocations - and its restrictions are noise you do not need.&lt;/p&gt;

&lt;p&gt;In Schemity, Supabase is a &lt;a href="https://schemity.com/doc/connect-postgresql/" rel="noopener noreferrer"&gt;first-class connection type&lt;/a&gt; alongside PostgreSQL, MySQL, MariaDB, SQL Server and SQLite: paste the session string, test the connection before saving it, and the password goes into your operating system's keychain rather than any config file. Tag the connection with its environment - Local, Staging, Production - so the list stays scannable as projects accumulate. Then &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;reverse engineer the schema into an ERD&lt;/a&gt;: every table arrives with its foreign keys drawn as crow's foot relationships, and the detail the dashboard never shows is on the entity itself - unique constraints as underlined badges, &lt;code&gt;IN&lt;/code&gt; check constraints marking enum-like fields, defaults and nullability printed next to each column.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fschemity.com%2Fimages%2Fblog%2Fsupabase-schema-diagram%2Fsupabase-schemity.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fschemity.com%2Fimages%2Fblog%2Fsupabase-schema-diagram%2Fsupabase-schemity.svg" alt="The same database reverse engineered in Schemity and exported as a 99 KB true-vector SVG: crow's foot relationships, primary and foreign key icons, unique badges, and per-field detail on every entity - zoom in and it stays sharp" width="1212" height="1019"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because this is a desktop ERD tool reading your database directly, the workflow also survives the situations the dashboard cannot: the diagram opens offline on a flight, and the schema of a client's project never transits any third-party diagram cloud on the way to being documented.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layout you arrange is the part you own
&lt;/h2&gt;

&lt;p&gt;Reverse engineering gives you the truth; arranging it gives you the understanding. Drag the auth-adjacent tables together, group each domain under a colored legend, route the lines until the picture reads top to bottom - this is analysis work, and in the Studio visualizer it stays locked inside one browser's localStorage.&lt;/p&gt;

&lt;p&gt;Schemity persists it structurally. The diagram is a plain JSON file in a folder on your machine, which means &lt;a href="https://schemity.com/doc/version-control-git/" rel="noopener noreferrer"&gt;the ERD can live in your Git repository&lt;/a&gt; and the layout is versioned along with the model. When the schema changes - and on a Supabase project it changes every time you run a migration or click through the Table Editor - &lt;a href="https://schemity.com/doc/resync-database/" rel="noopener noreferrer"&gt;re-sync pulls the fresh schema on reopen&lt;/a&gt; while every entity you arranged keeps its position. New tables appear ready to drag into place; dropped ones vanish. The live database stays the source of truth for structure, and you stay the source of truth for the picture.&lt;/p&gt;

&lt;p&gt;One diagram also does not have to carry everything. A context view is a focused sub-diagram that displays a chosen subset of the main diagram while the main view remains the single source of truth. Carve one per domain - orders, billing, content - and an orange dot on any entity signals a relationship pointing outside the view, so &lt;a href="https://schemity.com/doc/context-views/" rel="noopener noreferrer"&gt;the focused picture never silently lies about coupling&lt;/a&gt;. For a project whose &lt;code&gt;public&lt;/code&gt; schema has grown past what one canvas can say, that is the difference between a diagram people open and &lt;a href="https://schemity.com/blog/you-dont-need-a-diagram-of-all-800-tables/" rel="noopener noreferrer"&gt;a wall poster people avoid&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the diagram back out
&lt;/h2&gt;

&lt;p&gt;The last thing a dashboard viewer cannot do is hand the picture to someone else, and it is where an owned file pays off repeatedly. Schemity exports the main view or any context view as SVG - sharp at every zoom level - as well as PNG and JPG for chat threads, and full SQL DDL when the question is really about the statements. It also exports a Mermaid &lt;code&gt;erDiagram&lt;/code&gt;, which GitHub, GitLab, Notion and Obsidian render natively: the Supabase schema diagram in your README becomes text that renders as a diagram, reviewable in the same pull request as the migration that changed it.&lt;/p&gt;

&lt;p&gt;The dashboard visualizer answers "what does the schema look like right now, while I am logged in." A reverse-engineered, re-synced, Git-versioned ERD answers the better question - "what is our data model, and how has it changed" - and answers it for the whole team, indefinitely. Point the tool at the database once, arrange the picture once, and the &lt;a href="https://schemity.com/blog/keeping-your-erd-updated-shouldnt-be-a-second-job/" rel="noopener noreferrer"&gt;diagram stops being a second job&lt;/a&gt; without ever going stale.&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>supabase</category>
      <category>sql</category>
    </item>
    <item>
      <title>ON DELETE CASCADE Is Invisible in Your ERD and in Your Database Logs</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 30 Jul 2026 11:38:06 +0000</pubDate>
      <link>https://dev.to/tbson87/on-delete-cascade-is-invisible-in-your-erd-and-in-your-database-logs-4pdf</link>
      <guid>https://dev.to/tbson87/on-delete-cascade-is-invisible-in-your-erd-and-in-your-database-logs-4pdf</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A cascading delete can travel through several tables, and neither a typical diagram nor MySQL's binlog will tell you how far it went - InnoDB runs cascades inside the storage engine, so child deletions never reach the SQL layer. Schemity stores ON DELETE and ON UPDATE on every relationship, draws a bold crow's foot at the child end of any relationship set to CASCADE, and surfaces a change to that action in the migration SQL diff before it runs, so the reach of a delete is readable on the canvas instead of buried in DDL.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The reach of a &lt;code&gt;DELETE&lt;/code&gt; is decided by a property that most diagram tools never store and one major database never logs. That property is the referential action - &lt;code&gt;CASCADE&lt;/code&gt;, &lt;code&gt;RESTRICT&lt;/code&gt;, &lt;code&gt;SET NULL&lt;/code&gt; - and it lives on the foreign key constraint, which means it belongs in the ERD every bit as much as the crow's foot does.&lt;/p&gt;

&lt;p&gt;Ask a team what happens when a customer row is deleted and you get confident answers. Ask them to point at where that behavior is written down and the confidence drains. It is not in the diagram on the wiki, because the diagram draws a line between &lt;code&gt;customers&lt;/code&gt; and &lt;code&gt;orders&lt;/code&gt; and stops there. It is not in the ORM, or at least not reliably - the ORM has its own cascade setting, which may or may not agree with the database's. It is in the DDL of a constraint someone wrote three years ago, and the only way to know is to go read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually goes wrong with cascading deletes
&lt;/h2&gt;

&lt;p&gt;The failure mode is not subtle when it happens. In a long-running &lt;a href="https://www.sqlservercentral.com/forums/topic/to-cascade-delete-or-not" rel="noopener noreferrer"&gt;SQL Server Central thread on whether to use cascade delete&lt;/a&gt;, one practitioner explains that he never uses cascade deletes after an experience with "a root delete cascading to an empty database" - a single statement at the top of a dependency tree that emptied everything below it. Another calls the feature "at least as dangerous as it is powerful," and recommends having proven recovery processes in place before turning it on rather than banning it outright. A third names the real problem precisely: cascade is &lt;em&gt;hidden functionality&lt;/em&gt;. The row that disappeared was not named in any statement anyone ran.&lt;/p&gt;

&lt;p&gt;The strongest defense in that same thread is also correct - cascades exist because before the standard had them, people implemented the same logic in triggers, inconsistently, and broke referential integrity doing it. Cascade is the right tool for rows that are a logical extension of their parent. Order lines have no meaning without the order. The problem is not the feature. The problem is that the decision to use it is invisible everywhere except the DDL.&lt;/p&gt;

&lt;p&gt;And on MySQL, invisible is close to literal. A &lt;a href="https://rendiment.io/mysql/innodb/2026/02/25/mysql-fk-cascade-blind-spot.html" rel="noopener noreferrer"&gt;February 2026 analysis of InnoDB's cascade blind spot&lt;/a&gt; walks through the consequence: InnoDB handles cascade enforcement entirely inside the storage engine, so the child deletions never surface at the SQL layer. In row-based binlog with full image, only the parent deletion is recorded - the child table deletions are nowhere to be found. Audit plugins, including MySQL Enterprise Audit and the Percona Audit Log, miss them for the same reason. Triggers on the child table do not fire; the MySQL manual says so outright, and it has been tracked as bug #11472 since 2005. The article notes MySQL 9.6, released in January 2026, finally moves cascade handling to the SQL layer - which is welcome, and does nothing for the 8.x server you are running today.&lt;/p&gt;

&lt;p&gt;So on the most widely deployed configuration of one of the most widely deployed databases, a cascading delete is a set of rows that vanish without appearing in the binlog, the audit log, or any trigger. The only place that behavior is declared is the schema. Which is exactly the artifact your ERD is supposed to be a readable rendering of.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I see what ON DELETE CASCADE will actually delete?
&lt;/h2&gt;

&lt;p&gt;You trace the foreign keys outward from the table you are deleting from, following every child relationship whose action is &lt;code&gt;CASCADE&lt;/code&gt;, and then follow the children's children. That traversal is the answer, and it is the thing an entity-relationship diagram is uniquely good at showing - if the diagram carries the actions. In Schemity it does, and it draws them: any relationship set to &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; gets a bold crow's foot at the child end, so the cascading edges of a table stand out from the ordinary ones without opening a single dialog.&lt;/p&gt;

&lt;p&gt;This is why referential actions are not a detail to leave in the DDL. Cardinality tells you &lt;em&gt;whether&lt;/em&gt; a customer can have many orders. The referential action tells you &lt;em&gt;what happens to those orders when the customer is gone&lt;/em&gt;. Both are facts about the same foreign key constraint, and a diagram that renders one while dropping the other is doing half of its job:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question about a foreign key&lt;/th&gt;
&lt;th&gt;Where the answer lives&lt;/th&gt;
&lt;th&gt;Typical diagram&lt;/th&gt;
&lt;th&gt;Schemity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One or many on each side?&lt;/td&gt;
&lt;td&gt;Whether the key is unique&lt;/td&gt;
&lt;td&gt;Crow's foot notation&lt;/td&gt;
&lt;td&gt;Crow's foot notation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optional or mandatory?&lt;/td&gt;
&lt;td&gt;Whether the key column is nullable&lt;/td&gt;
&lt;td&gt;Usually an optionality circle&lt;/td&gt;
&lt;td&gt;Optionality circle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What happens when the parent row is deleted?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ON DELETE&lt;/code&gt; on the constraint&lt;/td&gt;
&lt;td&gt;Rarely shown - dropped on import&lt;/td&gt;
&lt;td&gt;Bold crow's foot when set to &lt;code&gt;CASCADE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What happens when the parent key changes?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ON UPDATE&lt;/code&gt; on the constraint&lt;/td&gt;
&lt;td&gt;Rarely shown - same&lt;/td&gt;
&lt;td&gt;Set and read on the relationship&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third and fourth rows are where diagrams quietly lose information. A tool that imports a foreign key as "source column, target column, done" has already discarded the deletion semantics of the schema, and no amount of re-arranging the picture brings them back. It is the same class of loss as &lt;a href="https://schemity.com/blog/why-erd-tools-draw-1-1-relationships-as-1-n/" rel="noopener noreferrer"&gt;drawing a 1:1 relationship as 1:N because the unique constraint was ignored&lt;/a&gt;: the tool read the key and skipped the constraint's properties.&lt;/p&gt;

&lt;h2&gt;
  
  
  Referential actions belong on the relationship, not in a text file
&lt;/h2&gt;

&lt;p&gt;In Schemity, &lt;code&gt;ON DELETE&lt;/code&gt; and &lt;code&gt;ON UPDATE&lt;/code&gt; are set in the relation dialog alongside the relation type, with the full set of actions available - &lt;code&gt;CASCADE&lt;/code&gt;, &lt;code&gt;SET NULL&lt;/code&gt;, &lt;code&gt;RESTRICT&lt;/code&gt;, &lt;code&gt;NO ACTION&lt;/code&gt;, &lt;code&gt;SET DEFAULT&lt;/code&gt;. They are properties of the &lt;a href="https://schemity.com/doc/relationships/" rel="noopener noreferrer"&gt;relationship&lt;/a&gt; itself, stored in the diagram's JSON file next to the cardinality, which means they survive every round trip the diagram makes.&lt;/p&gt;

&lt;p&gt;That matters in both directions. When you &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;reverse engineer a live PostgreSQL, MySQL, or SQL Server schema&lt;/a&gt;, the foreign keys arrive with their referential actions attached, so the diagram you get back describes deletion behavior your production database already has - including the cascades nobody remembers adding. And when you change one, Schemity generates the &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL diff&lt;/a&gt; for you to read before anything runs, so switching a relationship from &lt;code&gt;RESTRICT&lt;/code&gt; to &lt;code&gt;CASCADE&lt;/code&gt; shows up as a reviewable statement rather than a checkbox someone ticked. Since the diagram is a plain JSON file, the same change also shows up as a line in a pull request diff, which is the second place a teammate can catch it.&lt;/p&gt;

&lt;p&gt;Stored is necessary but not sufficient - a property you have to open a dialog to read is still effectively hidden in a diagram of eighty tables. So a relationship whose &lt;code&gt;ON DELETE&lt;/code&gt; is &lt;code&gt;CASCADE&lt;/code&gt; is drawn with a bold crow's foot at the child end. Ordinary relationships keep the normal weight; the cascading ones thicken. Scanning a table's outbound edges tells you immediately which of them will take rows with them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd5jfgvduzuhgddwi7pso.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd5jfgvduzuhgddwi7pso.webp" alt="An audit legend containing the id_token_logs, pwd_histories and member_logs entities, each with a bold crow's foot at the child end of its incoming relationship, marking the foreign keys set to ON DELETE CASCADE" width="800" height="567"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read that &lt;code&gt;audit&lt;/code&gt; legend and the deletion story tells itself. All three tables - &lt;code&gt;id_token_logs&lt;/code&gt;, &lt;code&gt;pwd_histories&lt;/code&gt;, &lt;code&gt;member_logs&lt;/code&gt; - hang off parents that live outside this group, and every one of those incoming relationships ends in a bold crow's foot. Deleting a member takes their password history and their event log with it, by design, and the diagram says so before anyone opens a constraint definition. It also shows how easily a cascade leaves its neighborhood - nothing inside the audit group is deleted directly, since the trigger for all three is a &lt;code&gt;DELETE&lt;/code&gt; somewhere else entirely.&lt;/p&gt;

&lt;p&gt;The signal is weight rather than color, and that is deliberate. Red would read as an error, and an error is something a designer feels obliged to fix - which is wrong here, because &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; on order lines under an order is a correct, considered decision. Bold says &lt;em&gt;this one needs special care&lt;/em&gt;, not &lt;em&gt;this one is broken&lt;/em&gt;. If you have already decided the cascade is right, you leave it, and the bold end remains as a note to the next person reading the diagram that a delete here is not local.&lt;/p&gt;

&lt;p&gt;Tracing a cascade chain is then an ordinary reading task on the canvas. Click a relationship and Schemity highlights the whole line together with the foreign key field on the child and the primary key field on the parent, so &lt;a href="https://schemity.com/blog/you-shouldnt-have-to-guess-which-line-goes-where/" rel="noopener noreferrer"&gt;which line goes where is never a guess&lt;/a&gt; even in a dense diagram. Set a color on the parent entity and every relationship originating from it inherits that color, so the outbound edges of the table you are about to delete from are visually grouped. When you want the exact text, right-click an entity or a legend to export its SQL and read the constraint definitions directly - no &lt;code&gt;information_schema&lt;/code&gt; query, no clicking through five property dialogs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cascade chains cross bounded contexts, and that is where they get expensive
&lt;/h2&gt;

&lt;p&gt;A cascade that stays inside one subject area is usually the intended kind. The dangerous kind leaves the neighborhood - a delete in the tenancy area that reaches into billing, or an identity cleanup that silently removes audit rows in a completely different module. Those are precisely the chains nobody traces, because nobody has the whole dependency structure in view at once.&lt;/p&gt;

&lt;p&gt;A context view is a focused subset of the main diagram that shows one part of the model without the rest of the schema around it. When you are inspecting the deletion behavior of a table inside a &lt;a href="https://schemity.com/doc/context-views/" rel="noopener noreferrer"&gt;context view&lt;/a&gt;, an orange dot on an entity tells you it still has at least one relationship to an entity that was not imported into this view - which is to say, this table's cascades may have effects you cannot see from here. A view that quietly hid those edges would let you conclude a delete is contained when it is not, which is the whole argument for why &lt;a href="https://schemity.com/blog/a-view-owes-you-back-the-context-it-removes/" rel="noopener noreferrer"&gt;a view owes you back the context it removes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One level up, the &lt;a href="https://schemity.com/doc/context-map/" rel="noopener noreferrer"&gt;Context Map&lt;/a&gt; renders each context view as a node and draws an arrow for every dependency between contexts, with a badge showing how many foreign keys flow in that direction. Because the arrows are derived from real foreign keys, they are the routes a cascade can travel. Double-click an arrow and a panel lists the exact relations behind it - source field, target entity, constraint name - so "billing depends on tenancy through 7 foreign keys" becomes seven named constraints you can go and check the &lt;code&gt;ON DELETE&lt;/code&gt; of. Arrow shape encodes health too: a straight arrow is a one-way dependency, a curved one means the two contexts depend on each other, and mutual dependency is where cascade reasoning stops being local. As with the rest of &lt;a href="https://schemity.com/blog/your-ddd-context-map-is-already-in-your-foreign-keys/" rel="noopener noreferrer"&gt;deriving your context map from the foreign keys you already have&lt;/a&gt;, none of this is hand-maintained - it is the schema, read at a different zoom level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Soft deletion does not remove the problem, it moves it
&lt;/h2&gt;

&lt;p&gt;The usual reaction to cascade anxiety is to stop deleting rows: add &lt;code&gt;deleted_at&lt;/code&gt;, filter every query, keep everything. It is a legitimate choice, and it comes with its own bill. In a widely discussed argument that &lt;a href="https://brandur.org/soft-deletion" rel="noopener noreferrer"&gt;soft deletion probably is not worth it&lt;/a&gt;, the author - who worked at Heroku and Stripe - reports that in ten plus years, never once did anyone at any of those places actually use soft deletion to undelete something, while every &lt;code&gt;SELECT&lt;/code&gt; in the codebase grew a &lt;code&gt;deleted_at IS NULL&lt;/code&gt; predicate that is dangerous to forget. And when a real hard-deletion requirement arrives, the query that implements it is described as five times longer than the illustrated version and spanning a full 30 separate tables - the same cascade traversal as before, now written by hand in application code.&lt;/p&gt;

&lt;p&gt;Soft deletion also changes what your constraints mean. A parent marked deleted still satisfies every foreign key pointing at it, so the database now believes in relationships the application considers dead. Uniqueness inverts too: &lt;code&gt;UNIQUE (email)&lt;/code&gt; counts soft-deleted rows, so a user who deletes their account cannot sign up again with the same address unless the rule becomes unique-among-live-rows. That is a &lt;a href="https://schemity.com/doc/check-constraints-composite-unique/" rel="noopener noreferrer"&gt;composite unique constraint&lt;/a&gt; or a partial unique index, not a single-column one, and it is exactly the kind of rule that deserves a named, colored badge on the diagram rather than a comment in a migration file - the same reason &lt;a href="https://schemity.com/blog/your-erd-is-hiding-your-domain-model/" rel="noopener noreferrer"&gt;check constraints belong in the ERD instead of hiding your domain model&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The point is not that soft deletion is wrong. It is that hard delete and soft delete are two different sets of rules about what happens when a row dies, and both of them are expressed in constraints. Whichever you choose, the choice should be legible in the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deletion semantics are part of the model
&lt;/h2&gt;

&lt;p&gt;An ERD is supposed to be the single source of truth for the schema - one artifact that answers structural questions without anyone having to open a SQL console. That promise only holds if the diagram carries the facts the schema carries. Cardinality without referential actions leaves the most consequential property of a foreign key sitting in DDL nobody reads, on an engine that will not log it when it fires.&lt;/p&gt;

&lt;p&gt;A desktop ERD tool that reads referential actions out of the live database, keeps them on the relationship, draws the cascading ones so they stand out, writes changes back through a reviewed migration, and stores the whole thing as JSON in your Git repository turns "what does this delete take with it" into something you can answer by looking - and something a reviewer can catch before it ships. That is a lower bar than it sounds like, and most tools still do not clear it.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>mysql</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Lucidchart ERD Alternative: A Desktop ERD Tool That Connects to Your Database</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Sat, 25 Jul 2026 04:03:09 +0000</pubDate>
      <link>https://dev.to/tbson87/lucidchart-erd-alternative-a-desktop-erd-tool-that-connects-to-your-database-od6</link>
      <guid>https://dev.to/tbson87/lucidchart-erd-alternative-a-desktop-erd-tool-that-connects-to-your-database-od6</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Lucidchart draws entity relationship diagrams on a general-purpose canvas, so the schema has to be carried in by hand: run a query, export a CSV, upload it, then drag tables onto the canvas one at a time. Schemity is the offline desktop alternative that connects to PostgreSQL, Supabase, MySQL, MariaDB, SQL Server or SQLite directly, reverse engineers the whole schema with its relationships intact, and stores every diagram as a plain JSON file in your Git repo for $129 once.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you want an ERD tool that reads your database instead of a file you exported from it, Lucidchart is the wrong shape of tool and Schemity is a direct answer: a native desktop app that opens a real connection, pulls the schema, and draws the diagram with foreign keys already wired. Lucidchart can absolutely render an ERD - but every path into it starts with you doing the extraction by hand.&lt;/p&gt;

&lt;p&gt;That is not a bug in Lucidchart. It is what happens when a database diagram is one shape library inside a general-purpose diagramming canvas that also draws org charts, flowcharts, and network topologies. The canvas is excellent. It just does not know what a foreign key is until you tell it, and it has no way to go and ask.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Honest Summary
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Lucidchart&lt;/strong&gt; is a browser-based visual collaboration suite with an Entity Relationship shape library. Multiplayer editing, comments, presentation mode, Confluence and Jira integrations, thousands of templates - all of it applies to your ERD the same way it applies to any other diagram. It is priced per seat, from a free tier through roughly $9 per user per month for Individual and up from there, and the free tier caps you at 3 editable documents with 60 shapes per document.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhlj0j1n5k5z0fwcwglex.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhlj0j1n5k5z0fwcwglex.webp" alt="Lucidchart's ERD on its general diagramming canvas: generic Entity/Field/Type placeholder boxes, a Shapes panel offering Standard and Flowchart shapes next to the database ones, and two collaborator cursors labeled Mike W and Danny P" width="800" height="669"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schemity&lt;/strong&gt; is an offline ERD tool for software engineers: a native desktop app that connects to PostgreSQL, Supabase, MySQL, MariaDB, SQL Server, and SQLite, reverse engineers the live schema into a diagram, generates the SQL migration diff when you change the model, and stores everything as plain JSON files you keep in Git. One $129 purchase, no account, no seats.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe4zsw7th3kv2797dlthv.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe4zsw7th3kv2797dlthv.webp" alt="Schemity showing the same kind of schema reverse-engineered from a live connection: real column names, types, defaults, crow's foot relationships and unique badges, grouped into colored legend regions, with a footer reading Entities: 66, Relations: 100, PostgreSQL: 18.3" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The distinction that matters is not cloud versus desktop. It is whether your diagram tool has a connection to the thing it is diagramming.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lucidchart Cannot Connect to Your Database
&lt;/h2&gt;

&lt;p&gt;The clearest statement of this comes from Lucid's own community forum. Asked whether there is a way to &lt;a href="https://community.lucid.co/product-questions-3/erd-diagrams-odbc-jdbc-reverse-engineer-db-and-sql-generation-2576" rel="noopener noreferrer"&gt;connect to a DB via ODBC/JDBC to reverse-engineer a schema into a Lucid ERD&lt;/a&gt;, the answer was that the database in question is not supported for ERD import, followed by the standing workaround: you could write a custom query and run it in your database if you'd like.&lt;/p&gt;

&lt;p&gt;Read that as a workflow and it is a chore with several failure points. You write a query against &lt;code&gt;information_schema&lt;/code&gt; in the exact shape Lucid's importer expects. You run it. You export the result as TSV or CSV. You upload the file. If anything about the output is off, you get a parse error and start again. Lucid has expanded the list of engines its importer understands - MySQL, PostgreSQL, Oracle, SQL Server, plus newer additions like Snowflake, Redshift, BigQuery and Azure SQL - but the mechanism underneath is still file upload. There is no connection to test, no credentials to store, nothing that can be re-run tomorrow when the schema has moved.&lt;/p&gt;

&lt;p&gt;Schemity starts at the other end. You &lt;a href="https://schemity.com/doc/connections-overview" rel="noopener noreferrer"&gt;create a connection&lt;/a&gt;, directly or through an SSH tunnel, test it before saving, and Schemity &lt;a href="https://schemity.com/doc/reverse-engineer-database" rel="noopener noreferrer"&gt;reverse engineers the schema into an ERD&lt;/a&gt; - tables, fields, types, defaults, constraints, and the foreign keys as actual crow's foot relationships. Passwords go into the operating system keychain, never a config file. Connections carry an environment tag - Local, Staging, Production - so the production connection is visually distinct in the list before you click it. Nothing about &lt;a href="https://schemity.com/blog/documenting-production-shouldnt-feel-dangerous" rel="noopener noreferrer"&gt;pointing a diagram tool at a real database&lt;/a&gt; has to be an act of faith.&lt;/p&gt;

&lt;p&gt;And because the connection persists, the diagram is not a one-time snapshot. Re-opening it pulls the current schema again: existing entities keep the positions you gave them, dropped tables disappear, new ones arrive ready to place. &lt;a href="https://schemity.com/blog/keeping-your-erd-updated-shouldnt-be-a-second-job" rel="noopener noreferrer"&gt;Keeping the ERD current stops being a separate job&lt;/a&gt; you keep postponing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Relationships Disappear When You Import an ERD Into Lucidchart
&lt;/h2&gt;

&lt;p&gt;The second symptom follows from the first. When schema arrives as a spreadsheet, the relationships arrive as columns in that spreadsheet, and whether they become lines on the canvas depends on the importer agreeing with your file. A Lucid community thread captures the experience exactly: &lt;a href="https://community.lucid.co/product-questions-3/import-an-erd-and-display-the-relationships-in-lucidchart-7710" rel="noopener noreferrer"&gt;I got my .csv file to import into LucidChart. I can see all of the tables. I clicked on the tables one at a time and no relationships were drawn.&lt;/a&gt; The documented behavior is that dragging related tables onto the canvas draws the relationship lines automatically when primary and foreign keys are set up correctly - which leaves the user debugging their CSV against an importer's expectations rather than looking at their schema.&lt;/p&gt;

&lt;p&gt;In a tool built around a database connection, this class of problem does not exist, because relationships are never re-derived from an intermediate file. Schemity reads the foreign key constraints themselves, and it reads their properties: cardinality comes from whether the foreign key is unique, so a 1:1 stays a 1:1 instead of &lt;a href="https://schemity.com/blog/why-erd-tools-draw-1-1-relationships-as-1-n" rel="noopener noreferrer"&gt;being flattened into a 1:N on the way in&lt;/a&gt;. ON DELETE and ON UPDATE behavior is configurable per relationship. Draw a new N:N by dragging between two entities and the junction table is created for you, with both foreign keys and a composite primary key already in place.&lt;/p&gt;

&lt;p&gt;The reverse direction is closed too. Change the model on the canvas and Schemity generates the &lt;a href="https://schemity.com/doc/migration-sql-diff" rel="noopener noreferrer"&gt;SQL migration diff&lt;/a&gt; for you to read - it runs against the database only when you decide to apply it. If you would rather not connect at all, &lt;a href="https://schemity.com/blog/stop-hand-translating-between-sql-and-your-erd" rel="noopener noreferrer"&gt;importing a SQL dump or a set of CREATE TABLE statements&lt;/a&gt; produces the same entities and relationships, and DBML import brings over schemas from dbdiagram.io and the wider DBML toolchain.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side-by-Side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Lucidchart&lt;/th&gt;
&lt;th&gt;Schemity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;What it is&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Visual collaboration suite; ERD is one shape library&lt;/td&gt;
&lt;td&gt;Focused offline ERD tool for relational schemas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Platform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Browser, cloud account required&lt;/td&gt;
&lt;td&gt;Native desktop app (macOS, Windows, Linux)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Getting schema in&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Run a custom query, export TSV/CSV, upload the file&lt;/td&gt;
&lt;td&gt;Direct connection or SSH tunnel; test before saving&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Databases&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Importer supports a fixed list; others via custom query&lt;/td&gt;
&lt;td&gt;PostgreSQL, Supabase, MySQL, MariaDB, SQL Server, SQLite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Relationships&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Derived from the imported file; must match the expected format&lt;/td&gt;
&lt;td&gt;Read from the actual foreign key constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Keeping it current&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Re-export and re-import&lt;/td&gt;
&lt;td&gt;Re-sync on open; layout preserved, drift applied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Applying changes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Export SQL and run it yourself&lt;/td&gt;
&lt;td&gt;Reviewed migration SQL diff, applied only on your approval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Where diagrams live&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Documents in Lucid's cloud&lt;/td&gt;
&lt;td&gt;Plain JSON files in a folder you choose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Collaboration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Real-time multiplayer editing, comments, presentations&lt;/td&gt;
&lt;td&gt;Single-designer by design; review happens in Git&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pricing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Per seat, ongoing; free tier limited to 3 documents / 60 shapes&lt;/td&gt;
&lt;td&gt;$129 one-time, unlimited diagrams&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Placing 199 Tables by Hand
&lt;/h2&gt;

&lt;p&gt;A third complaint in the same forum is the one that scales worst. A user importing a real database asked whether there is an easy way to add all tables, because adding one at a time for 199 tables is tedious and liable to errors as scrolling is terrible. The suggested fix is to drag the whole imported database onto the canvas at once - which solves the dragging and immediately produces the other problem, a single canvas holding 199 tables that nobody can read.&lt;/p&gt;

&lt;p&gt;This is where a general canvas and a database tool genuinely diverge, because the answer is not a better auto-layout. It is that &lt;a href="https://schemity.com/blog/you-dont-need-a-diagram-of-all-800-tables" rel="noopener noreferrer"&gt;one diagram of every table was never the deliverable&lt;/a&gt;. Schemity reverse engineers the whole schema into a main view, gives you two predefined layouts to get past the initial pile, and then provides the tools that make a large schema navigable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fuzzy search across entity and field names.&lt;/strong&gt; Type any fragment you remember - not necessarily contiguous - and jump straight to the entity or field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legends&lt;/strong&gt; group related entities into named, colored regions on the canvas, so a 199-table diagram reads as a dozen domains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://schemity.com/doc/context-views" rel="noopener noreferrer"&gt;Context views&lt;/a&gt;&lt;/strong&gt; carve the schema into focused sub-diagrams. A context view is a focused subset of the main diagram that you arrange freely while the schema itself stays untouched, so the main view remains the single source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Context Map&lt;/strong&gt; then renders each context as a single node with arrows for the foreign keys flowing between them - &lt;a href="https://schemity.com/blog/your-ddd-context-map-is-already-in-your-foreign-keys" rel="noopener noreferrer"&gt;the architecture view your schema already contains&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9w7t9c15utjeeau1sk1q.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9w7t9c15utjeeau1sk1q.webp" alt="Schemity's fuzzy search panel over the CMS schema: typing the fragment bgr instantly surfaces slides &gt; background, slides &gt; background_color, training_templates &gt; background and training_templates &gt; background_color" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instead of one canvas nobody can read, the schema splits into a list of named context views - each a bounded subject area you can open on its own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzirqnhexlb5pf6v12hyj.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzirqnhexlb5pf6v12hyj.webp" alt="Schemity's Context Views panel listing the CMS schema split into named subject areas - Auth, catalog, mission, account, localization, targeting, assistant, attribute, progress, config, speech, learning - beside the Main view, with a Context Map button in the header" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the Context Map draws the dependencies between those contexts from the actual foreign keys, with a count on every arrow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdq3feyag0qswngph5mi5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdq3feyag0qswngph5mi5.webp" alt="Schemity's Context Map rendering each context view as a single node - learning with 17 entities, Auth with 8, targeting with 8, catalog with 3 - and arrows labeled with the number of foreign keys flowing between them, account sitting at the center as the most depended-on context" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nine readable diagrams beat one wall poster, and a tool that models perspectives natively gets you there without duplicating a single entity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Lucidchart Wins
&lt;/h2&gt;

&lt;p&gt;The concessions are real, and for some teams they decide it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time collaboration.&lt;/strong&gt; Several people editing one diagram simultaneously, with cursors, comments, and @-mentions. Schemity does not do this and does not intend to - it takes a deliberate single-designer position where one person holds the pen and feedback arrives through review. If your process depends on a live shared canvas, Lucidchart is built for it and Schemity is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything else on the canvas.&lt;/strong&gt; Your ERD sits next to the sequence diagram, the org chart, the flowchart, and the whiteboard workshop from last sprint - in one document, in one product, with one integration story into Confluence and Jira. Schemity draws database diagrams and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero installation and universal access.&lt;/strong&gt; A browser tab and a link. Non-technical stakeholders can open and comment without installing anything. Schemity's read-only diagram mode lets someone explore a diagram without database access, but it is still an application they have to install.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A free entry point.&lt;/strong&gt; Three documents and sixty shapes is not much, but it is enough to try the tool without a card. Schemity's answer is a two-week full trial, after which designing keeps working.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is a Good Lucidchart Alternative for Entity Relationship Diagrams?
&lt;/h2&gt;

&lt;p&gt;It depends on which half of Lucidchart you were actually using. If the value was the shared canvas and the whiteboard culture around it, no database-specific tool will replace that, and you should keep Lucidchart for the diagrams that are genuinely about communication.&lt;/p&gt;

&lt;p&gt;If the value was the ERD - if you are diagramming a database that exists, or designing one that will - then the useful alternative is a tool whose primitives are tables, foreign keys, and migrations rather than shapes and connectors. Schemity is that tool in its offline desktop form: connect, reverse engineer, model with the keyboard, review the migration diff, and keep &lt;a href="https://schemity.com/doc/json-storage-format" rel="noopener noreferrer"&gt;every diagram as a JSON file&lt;/a&gt; that lives in the repo next to the code it describes. That last part changes the review workflow more than anything else on this page - a schema change shows up as &lt;a href="https://schemity.com/blog/erd-lives-in-your-git-repo" rel="noopener noreferrer"&gt;a readable diff in a pull request&lt;/a&gt;, not as a screenshot pasted into a ticket.&lt;/p&gt;

&lt;p&gt;There is also the arithmetic. A per-seat subscription is a permanent line item; the diagram stays accessible exactly as long as you keep paying, and &lt;a href="https://schemity.com/blog/your-erd-tool-shouldnt-count-your-tables" rel="noopener noreferrer"&gt;what you can model gets metered along the way&lt;/a&gt;. A $129 one-time purchase ERD tool with unlimited local workspaces has neither property.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who Should Use Lucidchart
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Your diagrams are a team conversation and real-time co-editing is the point&lt;/li&gt;
&lt;li&gt;The ERD is one artifact among many kinds of diagram in the same workspace&lt;/li&gt;
&lt;li&gt;Stakeholders who will never install a desktop app need to open and comment&lt;/li&gt;
&lt;li&gt;Confluence, Jira, and the rest of the Atlassian surface are where your documentation lives&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who Should Use Schemity
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You are diagramming a database that exists, and want the tool to read it directly&lt;/li&gt;
&lt;li&gt;Your engines are PostgreSQL, Supabase, MySQL, MariaDB, SQL Server, or SQLite&lt;/li&gt;
&lt;li&gt;The ERD should live in Git, be reviewed in pull requests, and survive any vendor&lt;/li&gt;
&lt;li&gt;Schema changes should produce a migration diff you approve, not SQL you hand-write&lt;/li&gt;
&lt;li&gt;One purchase, no seats, no meter on how many tables you are allowed to model&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Lucidchart is a strong diagramming product that happens to include ERD shapes. That framing explains every friction in this post: no live connection, relationships that depend on the file you fed it, and 199 tables to place by hand. If your diagram is a picture of a discussion, none of that matters.&lt;/p&gt;

&lt;p&gt;If your diagram is a picture of a database, it matters constantly - and the fix is a desktop ERD tool that opens a connection, reads the constraints, keeps the layout, and writes the file to disk where Git can see it.&lt;/p&gt;

&lt;p&gt;Comparing the database-native tools instead?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The cloud-generation schema visualizer:&lt;/strong&gt; see &lt;a href="https://schemity.com/blog/schemity-vs-chartdb" rel="noopener noreferrer"&gt;ChartDB alternative&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The DSL-based sketch pad:&lt;/strong&gt; see &lt;a href="https://schemity.com/blog/schemity-vs-dbdiagram-io" rel="noopener noreferrer"&gt;dbdiagram.io alternative&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The full desktop database IDE:&lt;/strong&gt; see &lt;a href="https://schemity.com/blog/schemity-vs-dbschema" rel="noopener noreferrer"&gt;DbSchema alternative&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>tooling</category>
      <category>programming</category>
    </item>
    <item>
      <title>Comparing Staging and Production Database Schemas Side by Side</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 23 Jul 2026 03:32:25 +0000</pubDate>
      <link>https://dev.to/tbson87/comparing-staging-and-production-database-schemas-side-by-side-il5</link>
      <guid>https://dev.to/tbson87/comparing-staging-and-production-database-schemas-side-by-side-il5</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Every environment runs a slightly different schema, and comparing them usually means diffing two SQL dumps line by line. Schemity reverse-engineers each environment into its own diagram in the same workspace, then merge-aware paste transfers one layout onto the other so the tables missing from the target arrive as dashed drafts - the drift, drawn.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Open your staging database and your production database as two diagrams in the same Schemity workspace, copy every entity from one and paste it into the other, and the drift draws itself: tables that exist in both snap into the same positions, and tables that exist only in the source arrive with a dashed border. No dump, no diff, no scrolling.&lt;/p&gt;

&lt;p&gt;You already know the schemas don't match. That is not the hard part. The hard part is that nobody can say &lt;em&gt;how&lt;/em&gt; they don't match without an afternoon of forensic work, and so the answer is usually a shrug and a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why staging and production schemas drift apart
&lt;/h2&gt;

&lt;p&gt;The gap opens through completely ordinary behavior. Something breaks in production at an inconvenient hour and someone runs an &lt;code&gt;ALTER TABLE&lt;/code&gt; directly against it, meaning to backport the change into the migration files later. Red Gate, describing how this actually plays out, notes that &lt;a href="https://www.red-gate.com/hub/product-learning/sql-compare/automated-database-comparisons" rel="noopener noreferrer"&gt;many DBAs are forced to short-circuit the usual dev-test-prod model&lt;/a&gt; and apply the fix straight to production, because standing up a parallel hotfix environment "would cost too much in resources, time and money." Bytebase calls emergency hotfixes the number-one source of schema drift, for exactly this reason.&lt;/p&gt;

&lt;p&gt;Then the environments diverge on their own. Neon's account of &lt;a href="https://neon.com/blog/why-your-staging-database-never-matches-production" rel="noopener noreferrer"&gt;why your staging database never matches production&lt;/a&gt; puts numbers on the baseline: with a nightly refresh, "staging is between 0 and 24 hours behind production," and on a weekly cycle, 168 hours behind. Indexes are where it bites hardest - one added to production to rescue a slow query and never propagated back, so the same query "completes in 50ms in staging and times out after 30 seconds in production."&lt;/p&gt;

&lt;p&gt;And migration tooling will not catch it for you. Flyway, Liquibase and Alembic record which migrations ran; none of them verify that the resulting schema is the one you expect. A migration marked applied says nothing about what somebody did to the table afterwards by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I compare database schemas between staging and production?
&lt;/h2&gt;

&lt;p&gt;The standard answers are all textual, and text is the wrong shape for this question.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;th&gt;What it costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg_dump -s&lt;/code&gt; both, then &lt;code&gt;diff&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Every difference, exactly&lt;/td&gt;
&lt;td&gt;Sequence values, comment lines and column ordering create noise; a 200-table schema is thousands of lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;information_schema&lt;/code&gt; comparison queries&lt;/td&gt;
&lt;td&gt;Precise column-level answers&lt;/td&gt;
&lt;td&gt;You have to know what to ask before you ask it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vendor schema-compare tools&lt;/td&gt;
&lt;td&gt;A change script&lt;/td&gt;
&lt;td&gt;Usually a per-seat subscription, and often SQL Server only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two diagrams, side by side&lt;/td&gt;
&lt;td&gt;Where the difference sits in the model&lt;/td&gt;
&lt;td&gt;You need a tool that can hold both environments at once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the one that answers the question people are really asking. &lt;code&gt;orders&lt;/code&gt; gained a &lt;code&gt;fulfilment_status&lt;/code&gt; column in production is a fact. &lt;em&gt;The fulfilment domain has a column in production that no other environment has, and three tables around it reference it&lt;/em&gt; is an answer. Only the second one tells you whether the next deploy is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Holding two environments in one workspace
&lt;/h2&gt;

&lt;p&gt;A Schemity workspace holds as many connections and diagrams as you want, so &lt;code&gt;app-production&lt;/code&gt;, &lt;code&gt;app-staging&lt;/code&gt; and &lt;code&gt;app-local&lt;/code&gt; live next to each other in one list. Tag each connection by environment - Local, Staging, Production - and the list stays scannable, so you never have to squint at a host string to work out which database you are about to read. &lt;a href="https://schemity.com/doc/connections-overview" rel="noopener noreferrer"&gt;Test the connection before saving it&lt;/a&gt;, and every password goes into your operating system's keychain rather than a config file.&lt;/p&gt;

&lt;p&gt;Then &lt;a href="https://schemity.com/doc/reverse-engineer-database" rel="noopener noreferrer"&gt;reverse-engineer each one into its own diagram&lt;/a&gt;. Open them at once and switch with Ctrl/Cmd + 1, 2, 3 - the same schema, three environments, one keystroke apart. Because Schemity is a &lt;strong&gt;desktop ERD tool&lt;/strong&gt; working over your own connections, all three reads happen from your machine; nothing about your production schema is uploaded anywhere to make the comparison possible.&lt;/p&gt;

&lt;p&gt;That alone is worth the setup. But flipping between two tabs only helps if the two pictures are arranged the same way, and a fresh reverse-engineer never is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Merge-aware paste turns the layout into the diff
&lt;/h2&gt;

&lt;p&gt;This is where the comparison becomes visual. Select every entity in the production diagram, copy, switch to the staging tab, and paste. Schemity matches entities by name and treats the two cases differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An entity that already exists in staging&lt;/strong&gt; receives only the visual attributes - position, size and color, with the color carrying through to its relationship lines. The schema in staging is not touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An entity that does not exist in staging yet&lt;/strong&gt; is pasted in full, with a dashed border marking it as a draft that the database has not confirmed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So after one paste, staging is laid out exactly like production, and every dashed box on the canvas is a table production has that staging does not. Reverse the direction and you get the other half of the answer. The drift is not a report you read - it is the shape of the picture.&lt;/p&gt;

&lt;p&gt;Nothing has been written to staging. Dashed entities are &lt;a href="https://schemity.com/doc/migration-sql-diff" rel="noopener noreferrer"&gt;drafts, not changes&lt;/a&gt;: on a live-connected diagram the schema only persists after a migration succeeds, and saving warns you about entities the database has never heard of. If you decide the gap should be closed, the generated migration SQL diff hands you the &lt;code&gt;ALTER&lt;/code&gt; statements to review - your decision, applied deliberately, not a sync button.&lt;/p&gt;

&lt;p&gt;For field-level differences inside tables that exist in both, the diagram carries the detail on its face: nullable flags, defaults, unique badges and check-constraint underlines are all &lt;a href="https://schemity.com/blog/finding-a-column-shouldnt-take-a-sql-query" rel="noopener noreferrer"&gt;readable without opening a single dialog&lt;/a&gt;, and fuzzy search jumps you to any column by a fragment of its name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the comparison honest over time
&lt;/h2&gt;

&lt;p&gt;A one-off comparison is a snapshot. What makes it a practice is that each diagram &lt;a href="https://schemity.com/blog/keeping-your-erd-updated-shouldnt-be-a-second-job" rel="noopener noreferrer"&gt;re-syncs itself against its own database every time you open it&lt;/a&gt; - so the production diagram is always today's production, and the staging diagram is always today's staging. You are never comparing two stale pictures of each other.&lt;/p&gt;

&lt;p&gt;Underneath, every diagram is &lt;a href="https://schemity.com/doc/json-storage-format" rel="noopener noreferrer"&gt;a plain JSON file in a folder you can commit&lt;/a&gt;. Commit all three environment diagrams into the repo and the drift acquires a history: &lt;code&gt;git diff&lt;/code&gt; shows what changed in production between Tuesday and Friday, reviewable in a pull request alongside the migration that should have caused it. That is the same argument as &lt;a href="https://schemity.com/blog/erd-lives-in-your-git-repo" rel="noopener noreferrer"&gt;keeping the ERD in your Git repo&lt;/a&gt;, pointed at a different problem - not documenting one schema, but auditing the distance between several.&lt;/p&gt;

&lt;p&gt;If the schemas are large, scope the comparison. A context view is a read-only, focused subset of the main diagram, showing one domain's entities without altering the model behind it. Compare the billing context across environments instead of all 200 tables, and export the SQL of just that view when you need the DDL. When the question is architectural rather than column-level, &lt;a href="https://schemity.com/blog/your-ddd-context-map-is-already-in-your-foreign-keys" rel="noopener noreferrer"&gt;the Context Map answers it at the level of whole contexts&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Schema drift is not exotic. It is the ordinary residue of production being where the pressure lands - the environment where hotfixes happen, indexes appear, and nobody has time to backport. What you need is not a tool that prevents it, because nothing does. You need to be able to see it in ten seconds, on a picture you already understand, without your schema leaving the building to make it possible.&lt;/p&gt;

</description>
      <category>database</category>
      <category>devops</category>
      <category>sql</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Roles and Permissions Schema Design for Multi-Tenant RBAC (Part 2)</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 23 Jul 2026 03:31:44 +0000</pubDate>
      <link>https://dev.to/tbson87/roles-and-permissions-schema-design-for-multi-tenant-rbac-part-2-4gc0</link>
      <guid>https://dev.to/tbson87/roles-and-permissions-schema-design-for-multi-tenant-rbac-part-2-4gc0</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Roles belong to a tenant; permissions do not. A permission describes what the software can do, so it is system-wide and every tenant adapts to it. This part derives the roles, members_roles, roles_pems, and pems tables, and shows how a module and method pair extracted from your routing becomes the permission check your middleware runs on every request. A stable key on each permission survives module and method renames, and a deprecated_at timestamp retires removed permissions visibly instead of silently deleting grants from custom roles.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Multi-Tenant RBAC Data Model, Part 2: Roles, Permissions, and the Routing Table
&lt;/h2&gt;

&lt;p&gt;Roles carry a &lt;code&gt;tenant_id&lt;/code&gt;; permissions do not. That one asymmetry is the whole design. A role is something a tenant invents - "Regional Manager" means whatever that company decides it means - while a permission describes a capability of the software, and every tenant runs the same software. This part builds the four remaining tables on top of &lt;a href="https://schemity.com/blog/design-data-model-for-multi-tenant-rbac/" rel="noopener noreferrer"&gt;the tenants, users, and members foundation from Part 1&lt;/a&gt;, and then shows where the permission rows actually come from: your routing table.&lt;/p&gt;

&lt;p&gt;We are assuming a micro framework with no opinion about authorization - no built-in users, no roles, no permissions, nothing to configure. Everything gets built from scratch. That sounds like more work, and it is, but it means every table below exists for a reason we can state out loud.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Multi-Tenant RBAC Schema
&lt;/h2&gt;

&lt;p&gt;Here is where Part 1 was heading. Seven tables, and every relationship is one we can now derive.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxea71n9xvpfekug5tr96.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxea71n9xvpfekug5tr96.webp" alt="The complete multi-tenant RBAC ERD: users and tenants joined through members with a composite unique constraint over tenant_id and user_id, members joined to tenant-scoped roles through members_roles, and roles joined to system-wide pems through roles_pems"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read it from the outside in. &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;tenants&lt;/code&gt; are the two independent things. &lt;code&gt;members&lt;/code&gt; sits between them as the tenant-scoped membership. &lt;code&gt;roles&lt;/code&gt; hangs off &lt;code&gt;tenants&lt;/code&gt;. &lt;code&gt;pems&lt;/code&gt; hangs off nothing at all. And two junction tables - &lt;code&gt;members_roles&lt;/code&gt; and &lt;code&gt;roles_pems&lt;/code&gt; - carry the two N:N relationships that make role-based access control work: a member has many roles, and a role grants many permissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Roles Belong to a Tenant
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;roles&lt;/code&gt; carries &lt;code&gt;tenant_id&lt;/code&gt;, and the unique constraint spans &lt;code&gt;(tenant_id, name)&lt;/code&gt; rather than &lt;code&gt;name&lt;/code&gt; alone. Both facts follow from the same observation: the word "Admin" is not a global concept. Every tenant gets to have one, and two tenants' Admins have nothing to do with each other.&lt;/p&gt;

&lt;p&gt;Scoping the constraint to the pair is what makes that possible. A single-column unique index on &lt;code&gt;name&lt;/code&gt; would mean the first tenant to create "Manager" takes the name away from everyone else - which is exactly the kind of accidental cross-tenant coupling that only shows up in production, on the day your second customer signs up. Scoping it to &lt;code&gt;(tenant_id, name)&lt;/code&gt; says the real rule out loud: names must be unique &lt;em&gt;within&lt;/em&gt; a tenant, and mean nothing outside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Permissions Are System-Wide, Not Tenant-Scoped
&lt;/h2&gt;

&lt;p&gt;Now the table that surprises people: &lt;code&gt;pems&lt;/code&gt; has no &lt;code&gt;tenant_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The instinct in a multi-tenant schema is to stamp &lt;code&gt;tenant_id&lt;/code&gt; on everything, because tenant isolation is the thing you are most afraid of getting wrong. But a permission is not tenant data. A permission says &lt;em&gt;this software has an endpoint that archives a course&lt;/em&gt;. That fact is produced by your codebase, not by your customer. Every tenant is running the same deployment, hitting the same routes, exercising the same capabilities. There is exactly one true list of them.&lt;/p&gt;

&lt;p&gt;Think about what a &lt;code&gt;tenant_id&lt;/code&gt; on &lt;code&gt;pems&lt;/code&gt; would actually commit you to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duplication with no variance.&lt;/strong&gt; With 40 modules averaging 5 methods each, you have 200 permission rows. Add a &lt;code&gt;tenant_id&lt;/code&gt; and 500 tenants turn that into 100,000 rows that are all copies of the same 200 facts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A write on every deploy.&lt;/strong&gt; Ship a new endpoint and you now have to fan a new permission row out to every tenant, forever, as a migration that grows with your customer count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rows that can disagree.&lt;/strong&gt; The moment the same permission exists 500 times, 500 copies can drift. One tenant's row says the module is &lt;code&gt;course&lt;/code&gt;, another says &lt;code&gt;courses&lt;/code&gt;, and the middleware silently stops matching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the direction of adaptation runs one way: tenants adapt to permissions, not the other way around. A tenant cannot invent a capability the software does not have. What a tenant &lt;em&gt;can&lt;/em&gt; do is compose - build its own roles out of the fixed permission vocabulary, and call them whatever it likes. That is the entire freedom a tenant needs, and &lt;code&gt;roles&lt;/code&gt; already provides it.&lt;/p&gt;

&lt;p&gt;This is also why &lt;code&gt;pems&lt;/code&gt; sits alone on the diagram with a single line into it. It has no foreign key to anything. It is a vocabulary, and vocabularies do not belong to anyone.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;roles&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;pems&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;One tenant&lt;/td&gt;
&lt;td&gt;The whole system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Defined by&lt;/td&gt;
&lt;td&gt;The customer&lt;/td&gt;
&lt;td&gt;The codebase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changes when&lt;/td&gt;
&lt;td&gt;An admin edits them&lt;/td&gt;
&lt;td&gt;You deploy a new route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unique over&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(tenant_id, name)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;(module, method)&lt;/code&gt;, plus a stable &lt;code&gt;key&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Row count&lt;/td&gt;
&lt;td&gt;Grows with customers&lt;/td&gt;
&lt;td&gt;Grows with features&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where Permission Rows Come From: Module and Method
&lt;/h2&gt;

&lt;p&gt;If permissions are produced by the codebase, they should be &lt;em&gt;read&lt;/em&gt; from the codebase rather than typed by hand into a seed file - and never frozen into &lt;a href="https://schemity.com/blog/postgres-enum-vs-check-constraint-vs-lookup-table/" rel="noopener noreferrer"&gt;a database enum type whose value set only a migration can change&lt;/a&gt;. That is what &lt;code&gt;module&lt;/code&gt; and &lt;code&gt;method&lt;/code&gt; are for.&lt;/p&gt;

&lt;p&gt;Organize every feature as a module, and every module as a set of methods. &lt;code&gt;course&lt;/code&gt; has &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;archive&lt;/code&gt;. &lt;code&gt;report&lt;/code&gt; has &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;export&lt;/code&gt;. The pair is what identifies a capability, which is why the unique constraint on &lt;code&gt;pems&lt;/code&gt; spans &lt;code&gt;(module, method)&lt;/code&gt; - the same reasoning as &lt;code&gt;(tenant_id, name)&lt;/code&gt; on roles, applied to a different pair. Neither half means anything alone; &lt;code&gt;create&lt;/code&gt; is not a permission, and &lt;code&gt;course&lt;/code&gt; is not a permission, but &lt;code&gt;course.create&lt;/code&gt; is.&lt;/p&gt;

&lt;p&gt;The way to keep that list honest is to attach the pair to the route itself. Whatever your framework's routing looks like, every route gains two pieces of metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET    /courses            -&amp;gt; module: course,  method: list
POST   /courses            -&amp;gt; module: course,  method: create
GET    /courses/{id}       -&amp;gt; module: course,  method: read
PATCH  /courses/{id}       -&amp;gt; module: course,  method: update
POST   /courses/{id}/archive -&amp;gt; module: course, method: archive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the routing table &lt;em&gt;is&lt;/em&gt; the permission list. At deploy time, walk the registered routes, collect the distinct &lt;code&gt;(module, method)&lt;/code&gt; pairs, and upsert them into &lt;code&gt;pems&lt;/code&gt;. Adding an endpoint adds a permission automatically. More importantly, it becomes structurally difficult to ship an unguarded endpoint, because the route that has no module and method is the one that stands out in review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Each Permission Carries a Key That Never Changes
&lt;/h2&gt;

&lt;p&gt;Matching by &lt;code&gt;(module, method)&lt;/code&gt; has one failure mode, and it is the most ordinary act in a codebase: renaming something. Decide that &lt;code&gt;course&lt;/code&gt; should have been &lt;code&gt;courses&lt;/code&gt;, update the routes, and the next deploy's upsert finds no row for the new pair - so it inserts one. The old row now matches no route, so it reads as removed, and every grant that referenced it - in every tenant, custom roles included - is attached to a row the middleware will never look up again. A rename, the safest refactor you can make in code, becomes a silent permission wipe in the database.&lt;/p&gt;

&lt;p&gt;The fix is to separate what a permission is called from what it &lt;em&gt;is&lt;/em&gt;. Each route carries one more piece of metadata: a &lt;code&gt;key&lt;/code&gt; - an opaque unique string, a UUID generated once when the route is first written and hardcoded in source right next to &lt;code&gt;module&lt;/code&gt; and &lt;code&gt;method&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /courses -&amp;gt; key: 6b9d5f2a-..., module: course, method: create, default_roles: {Admin, Manager}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deploy-time sync upserts by &lt;code&gt;key&lt;/code&gt;: if the key exists, update its module, method and &lt;code&gt;default_role_names&lt;/code&gt; in place; if not, insert a new row. Renaming &lt;code&gt;course&lt;/code&gt; to &lt;code&gt;courses&lt;/code&gt; is now an UPDATE to one row that every grant already points at, and nothing anywhere is lost. The unique constraint over &lt;code&gt;(module, method)&lt;/code&gt; stays - the pair is still what the middleware matches at request time and still the human-readable identity - but the key is the identity that survives time.&lt;/p&gt;

&lt;p&gt;The rename hazard does not disappear; it moves onto a field whose only job is to never change, and which nobody has a reason to change, because a UUID does not drift toward better naming the way words do. The one discipline it demands is that keys are generated fresh, never copy-pasted from the route above - a duplicated key is the new way to corrupt the list, and it is exactly the kind of mistake a unique constraint on &lt;code&gt;key&lt;/code&gt; turns into a deploy-time error instead of a silent merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Default Roles Bootstrap a New Tenant
&lt;/h2&gt;

&lt;p&gt;There is a third piece of metadata on the route, and it is the one that pays off biggest: which default roles this capability belongs to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST   /configs   -&amp;gt; module: config,  method: create,  default_roles: {Admin}
POST   /courses   -&amp;gt; module: course,  method: create,  default_roles: {Admin, Manager}
GET    /courses   -&amp;gt; module: course,  method: list,    default_roles: {Admin, Manager, User}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creating a system config is an owner-level act, so it is &lt;code&gt;{Admin}&lt;/code&gt; and nothing else. Creating a course is ordinary operational work, so &lt;code&gt;{Admin, Manager}&lt;/code&gt;. Listing courses is something everyone does, so all three. The developer writing the endpoint is the person best placed to make that call, and they make it once, at the route, next to the code it guards. That array lands in &lt;code&gt;pems.default_role_names&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now invert it at tenant creation. Every permission already knows which default roles it belongs to, so the bootstrap routine reads the whole &lt;code&gt;pems&lt;/code&gt; table, groups by role name, and creates Admin, Manager and User for the new tenant with their grants already correct. No per-tenant configuration, no checklist, no human deciding for the four hundredth time that a manager can create a course. A brand-new tenant gets a complete, working set of roles for free, on the first request after signup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Those default roles are read-only.&lt;/strong&gt; A tenant can assign them to members as they are, but nobody - not even the tenant admin - can edit their permissions. Customization happens by &lt;strong&gt;duplicate and modify&lt;/strong&gt;: need a "Course Coordinator" who is a Manager without billing access? Copy Manager, drop the permissions that do not apply, save it under a new name. The copy belongs to the tenant and is theirs to change however they like.&lt;/p&gt;

&lt;p&gt;That restriction looks like a limitation and is actually the thing that makes the whole system maintainable, for a reason that only shows up on the next deploy. Hold onto it.&lt;/p&gt;

&lt;p&gt;Either way, this is the composition freedom from earlier made concrete: a tenant cannot invent capabilities, but it can slice the fixed vocabulary any way it likes, starting from a sensible default instead of a blank page.&lt;/p&gt;

&lt;p&gt;Storing this as an array rather than another junction table is deliberate. These are role &lt;em&gt;names&lt;/em&gt;, not foreign keys - they cannot point at any tenant's &lt;code&gt;roles&lt;/code&gt; rows, because at deploy time those rows do not exist yet, and after bootstrap each tenant has its own. The array is a template, read once per tenant and never joined against. &lt;a href="https://schemity.com/doc/tables-and-fields/" rel="noopener noreferrer"&gt;Array types are first-class in PostgreSQL&lt;/a&gt;, and this is exactly the case they are for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens to Existing Tenants When You Add a Route
&lt;/h2&gt;

&lt;p&gt;Nothing - until a sync runs, no existing tenant can use the new capability. Deploy &lt;code&gt;course.archive&lt;/code&gt; with &lt;code&gt;{Admin, Manager}&lt;/code&gt; and the permission row lands in &lt;code&gt;pems&lt;/code&gt; immediately, but &lt;code&gt;roles_pems&lt;/code&gt; holds no grant for it: tenant number one signed up last year, and its Admin role predates the route. So the deploy pipeline ends with a command - call it &lt;code&gt;syncrolepems&lt;/code&gt; - that walks every tenant and syncs its default roles against the &lt;code&gt;pems&lt;/code&gt; table: &lt;strong&gt;after it runs, each default role grants exactly the permissions that name it in &lt;code&gt;default_role_names&lt;/code&gt; - no more, no less.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In member terms: every member holding a default role picks up the new permission the moment the pipeline runs &lt;code&gt;syncrolepems&lt;/code&gt;, in every tenant at once. Members on custom roles get nothing - their roles stay exactly as their admin built them, until that admin grants the new permission on purpose.&lt;/p&gt;

&lt;p&gt;This is where the read-only rule pays off. No tenant can have edited a default role, so there is no local intent to preserve - no diffing against the last run, no working out which permissions are new, just asserting the desired state in both directions. The removal direction is the one an incremental, add-only backfill silently gets wrong: decide that archiving a course is an owner-level act after all, drop &lt;code&gt;Manager&lt;/code&gt; from the route's &lt;code&gt;default_role_names&lt;/code&gt;, and the next deploy revokes the grant from every tenant's Manager, because the desired state no longer contains it. Re-running is free - the composite primary key on &lt;code&gt;roles_pems&lt;/code&gt; makes every insert an on-conflict-do-nothing - so the command simply runs on every deploy.&lt;/p&gt;

&lt;p&gt;Matching is by name, which is the second reason &lt;code&gt;default_role_names&lt;/code&gt; stores names rather than ids: the same array that builds Admin, Manager and User for a new tenant locates the equivalent roles in every old one, and the &lt;code&gt;(tenant_id, name)&lt;/code&gt; unique constraint makes that lookup exact.&lt;/p&gt;

&lt;p&gt;The one hard boundary: &lt;strong&gt;never touch a user-defined role.&lt;/strong&gt; The Course Coordinator a tenant copied from Manager is their artifact, frozen at the moment of copying, and the deploy pipeline has no standing to add powers the admin never chose or remove ones they did. Route &lt;em&gt;removal&lt;/em&gt; is where that rule gets tested, because when a route disappears the capability genuinely no longer exists, and something has to happen to the grants pointing at it. The tempting implementation is to delete the &lt;code&gt;pems&lt;/code&gt; row and let the foreign key cascade take the grants - clean, and silent, which is exactly the problem. The admin who added that permission to their Course Coordinator yesterday opens the role tomorrow and finds it shorter than they left it, with nothing anywhere to say why.&lt;/p&gt;

&lt;p&gt;So removal is a state, not a deletion. When a key no longer appears in the routing table, the sync stamps that row's &lt;code&gt;deprecated_at&lt;/code&gt; timestamp instead of issuing a DELETE. A deprecated permission denies at the middleware exactly as if it were gone - the capability really did leave the software, and the check simply requires &lt;code&gt;deprecated_at&lt;/code&gt; to be null - but the &lt;code&gt;pems&lt;/code&gt; row itself stays.&lt;/p&gt;

&lt;p&gt;From there, the two kinds of role part ways, exactly along the ownership line drawn above. Default roles belong to the pipeline, so &lt;code&gt;syncrolepems&lt;/code&gt; drops the grant outright: the desired state no longer contains the deprecated permission, and no notification is owed for rows no tenant ever edited. Custom roles belong to the tenant, so their grants are left precisely where they are - and the role editor renders the permission as deprecated. The custom role shows what happened to it instead of hiding it: the permission is right there, visibly retired, and the admin who added it yesterday learns the vocabulary changed rather than suspecting their role was tampered with. And if the route comes back next sprint, the sync clears &lt;code&gt;deprecated_at&lt;/code&gt; on the same key - default roles get the grant re-asserted, and every custom-role grant resumes working, because none of them was ever destroyed.&lt;/p&gt;

&lt;p&gt;Seen whole, the &lt;code&gt;pems&lt;/code&gt; side of the sync is nothing but set membership on &lt;code&gt;key&lt;/code&gt;. Because keys never change, one pass compares the keys extracted from the routes against the keys in the table, and every row falls into one of four branches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key in the routes and in the table, with a different module or method&lt;/strong&gt; - a rename. Update the row in place; every grant stays attached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key in the routes but not in the table&lt;/strong&gt; - a new capability. Insert it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key in the table but not in the routes&lt;/strong&gt; - a deleted route. Stamp &lt;code&gt;deprecated_at&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key in both, but the row is stamped&lt;/strong&gt; - the route came back. Clear &lt;code&gt;deprecated_at&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A deleted route is an absence in source, not an event - but the sync never needs the event, because comparing full key sets makes the absence computable. And since all four branches are decided by key membership alone, renames and deletions cannot touch the same row, the branches run in any order, and the command stays safe to re-run blindly - the property this whole pipeline is built on.&lt;/p&gt;

&lt;p&gt;One edge deserves a comment in the sync code before it costs a debugging session: in-place renames under the &lt;code&gt;(module, method)&lt;/code&gt; unique constraint can transiently collide. If a deploy renames one permission onto the pair another permission just vacated, row-by-row UPDATE order decides whether the sync hits a unique violation halfway through. Declaring that constraint &lt;code&gt;DEFERRABLE INITIALLY DEFERRED&lt;/code&gt; and running the sync in a single transaction moves the check to commit time, after every rename has landed - which is precisely the situation that PostgreSQL feature exists for. Retiring a verb from the vocabulary and erasing every sentence that used it are different operations - the first is a deploy, the second is a decision, made later, if ever.&lt;/p&gt;

&lt;p&gt;One thing the ERD does not show: nothing in &lt;code&gt;roles&lt;/code&gt; marks which rows are the read-only defaults. You can derive it - a role is a default if its name appears in some permission's &lt;code&gt;default_role_names&lt;/code&gt;, and the unique constraint keeps that unambiguous - or you can make it explicit with a boolean column and let the database refuse the edit rather than the application layer. The derived version costs no schema change; the explicit one survives someone writing a second code path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Role Does a New Member Get?
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;members&lt;/code&gt; row appears the moment someone joins a tenant, and until they hold a role they can do precisely nothing. Something has to decide their starting permissions.&lt;/p&gt;

&lt;p&gt;The schema-shaped answer is another boolean on &lt;code&gt;roles&lt;/code&gt;: mark one row per tenant as the default, and assign it on signup. One column, per-tenant flexible, obvious. It is also the one column I deliberately leave out.&lt;/p&gt;

&lt;p&gt;Consider how it fails. A flag is a switch, and switches get flipped - by a support engineer in an admin panel, by a migration that seeds the wrong row, by a helpful script. Flip it onto Admin and nothing breaks. No error, no failed request, no alert. Every person who joins that tenant from then on arrives holding full administrative access, and the system is behaving exactly as configured. You find out weeks later, if you find out at all.&lt;/p&gt;

&lt;p&gt;That is what makes it disqualifying. It is not a one-time mistake, it is a standing condition that keeps manufacturing over-privileged accounts until somebody notices, and the blast radius grows with every signup.&lt;/p&gt;

&lt;p&gt;So the default role is named in source code instead. &lt;code&gt;User&lt;/code&gt; is the role with the fewest permissions, that fact is known while the routes are being written, and it belongs in the same place &lt;code&gt;default_role_names&lt;/code&gt; already lives - the codebase. Changing which role new members receive becomes a code change: reviewed, diffed, deployed, attributable to a person. It stops being a checkbox.&lt;/p&gt;

&lt;p&gt;The consequence is that privilege only ever increases by explicit human action. A new member who needs to be a Manager asks an admin to upgrade them, and somebody with the authority to grant it does so on purpose. That is a slightly worse user experience and a considerably better security posture: every escalation has a name attached, and the path of least resistance is the least-privileged one.&lt;/p&gt;

&lt;p&gt;This is a different kind of flag from the read-only marker discussed above, and the difference is the whole argument. A read-only marker is &lt;strong&gt;descriptive&lt;/strong&gt; - it states what a row is, and getting it wrong lets through an edit that should have been refused. An auto-assign marker is &lt;strong&gt;imperative&lt;/strong&gt; - it &lt;em&gt;causes&lt;/em&gt; privilege to be granted, on every future signup, with nobody looking. Those failure modes are not comparable, which is why one is a reasonable column and the other is not.&lt;/p&gt;

&lt;p&gt;It costs something, and the cost is worth naming: a tenant cannot decide that its new members start as Managers. Everyone starts at the bottom and gets promoted. Some customers will ask for it, and the flexible version is entirely buildable - the boolean is easy to add and it would work. Choosing not to add it is a judgment that a silent, persistent failure pointing toward more privilege is the wrong risk to accept for the convenience it buys.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Middleware Turns a Route into an Authorization Check
&lt;/h2&gt;

&lt;p&gt;The two ends now meet in the middle, and they meet on the same pair of strings.&lt;/p&gt;

&lt;p&gt;At &lt;strong&gt;deploy time&lt;/strong&gt;, the routing table writes into &lt;code&gt;pems&lt;/code&gt;: these are the capabilities that exist.&lt;/p&gt;

&lt;p&gt;At &lt;strong&gt;request time&lt;/strong&gt;, the middleware reads the matched route's metadata to learn which &lt;code&gt;(module, method)&lt;/code&gt; this request requires, then asks one question: does the current member hold a role that grants it? The check never looks at the URL, the HTTP verb, or the controller name. It compares a pair of strings from the route against a set of pairs belonging to the member.&lt;/p&gt;

&lt;p&gt;That is what makes the guarantee from Part 1 real - &lt;em&gt;even if they know the API specs and bypass the UI, they will still be blocked at the API layer&lt;/em&gt;. There is no UI-side permission logic to bypass, because the UI was never the thing enforcing it. The route declares what it needs; the middleware decides. A client that calls the endpoint directly hits exactly the same check.&lt;/p&gt;

&lt;p&gt;Resolving the member's permission set is a walk across the whole schema: &lt;code&gt;members&lt;/code&gt; to &lt;code&gt;members_roles&lt;/code&gt; to &lt;code&gt;roles&lt;/code&gt; to &lt;code&gt;roles_pems&lt;/code&gt; to &lt;code&gt;pems&lt;/code&gt;. Four joins to answer one boolean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Junction Tables Have No ID
&lt;/h2&gt;

&lt;p&gt;Both &lt;code&gt;members_roles&lt;/code&gt; and &lt;code&gt;roles_pems&lt;/code&gt; use the two foreign keys as their primary key, with no surrogate &lt;code&gt;id&lt;/code&gt; in sight. Part 1 gave &lt;code&gt;members&lt;/code&gt; its own &lt;code&gt;id&lt;/code&gt; and argued hard for it - so why the opposite answer here?&lt;/p&gt;

&lt;p&gt;Because these two really are nothing but pairings. &lt;code&gt;roles_pems&lt;/code&gt; has no lifecycle: the row means &lt;em&gt;this role grants this permission&lt;/em&gt; and it will never mean anything else. No status, no history worth keeping on the row itself, and nothing else in the schema will ever need a foreign key pointing at one specific grant. The pairing &lt;em&gt;is&lt;/em&gt; the row's identity, so &lt;a href="https://schemity.com/blog/many-to-many-shouldnt-mean-hand-building-the-junction-table/" rel="noopener noreferrer"&gt;making the two keys the primary key is the honest shape&lt;/a&gt;, and it comes with duplicate protection for free.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;members&lt;/code&gt; failed both of those tests, which is why it got an &lt;code&gt;id&lt;/code&gt;. Look at the &lt;code&gt;U&lt;/code&gt; badges on its &lt;code&gt;tenant_id&lt;/code&gt; and &lt;code&gt;user_id&lt;/code&gt; in the diagram: that composite unique constraint is doing the job the primary key does for &lt;code&gt;roles_pems&lt;/code&gt;. One membership per user per tenant, guaranteed either way - the difference is only whether that guarantee also has to serve as the row's identity.&lt;/p&gt;

&lt;p&gt;Same schema, same rule, opposite conclusions - which is the point. The shape of a junction table is a consequence of what the row &lt;em&gt;means&lt;/em&gt;, not a habit you apply uniformly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;members&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;members_roles&lt;/code&gt;, &lt;code&gt;roles_pems&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary key&lt;/td&gt;
&lt;td&gt;Surrogate &lt;code&gt;id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The two foreign keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pairing enforced by&lt;/td&gt;
&lt;td&gt;A composite unique constraint&lt;/td&gt;
&lt;td&gt;The primary key itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Has its own state&lt;/td&gt;
&lt;td&gt;Password, login attempts, status&lt;/td&gt;
&lt;td&gt;Nothing beyond the pairing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Referenced by other tables&lt;/td&gt;
&lt;td&gt;Yes, extensively&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And since the shape is a decision, the name is where to record it. The convention is small and worth adopting: &lt;strong&gt;pluralize both halves for a pure pairing, only the second half for a pairing that carries its own identity, and give the table a domain name once it is an entity in its own right.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;members_roles&lt;/code&gt;, &lt;code&gt;roles_pems&lt;/code&gt; - both plural. Composite key, nothing but the pairing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;member_roles&lt;/code&gt; - singular then plural. Surrogate &lt;code&gt;id&lt;/code&gt;, the two foreign keys as ordinary fields, a unique constraint over the pair. The form for a junction that spans two bounded contexts and may have to survive them splitting into separate databases - it needs an identity that does not depend on the pairing staying put, but it is still recognizably a pairing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;members&lt;/code&gt; - a name of its own. Mechanical naming would have produced &lt;code&gt;tenant_users&lt;/code&gt;, but the table on the left of that comparison is an entity that happens to join two tables, and the domain already has a word for what its rows represent. &lt;code&gt;tenant_users&lt;/code&gt; would describe how the table came to exist; &lt;code&gt;members&lt;/code&gt; describes what it is.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The payoff is that reading the schema stops requiring a lookup. &lt;code&gt;roles_pems&lt;/code&gt; tells you it is a composite-key pairing; &lt;code&gt;member_roles&lt;/code&gt; tells you it has an &lt;code&gt;id&lt;/code&gt; and a unique constraint, and that somebody thought about why; &lt;code&gt;members&lt;/code&gt; tells you to stop thinking of it as a junction at all. A name that disagrees with its own shape becomes an obvious thing to fix in review.&lt;/p&gt;

&lt;p&gt;Drawing these is also where an ERD tool earns its keep or gets in the way. In Schemity, dragging an N:N relationship between two entities &lt;a href="https://schemity.com/doc/auto-junction-tables/" rel="noopener noreferrer"&gt;creates the junction table with both foreign keys and the composite primary key already in place&lt;/a&gt;, so the default shape is the correct one for a pure pairing - and when a junction turns out to need its own identity, you add the &lt;code&gt;id&lt;/code&gt; and &lt;a href="https://schemity.com/doc/check-constraints-composite-unique/" rel="noopener noreferrer"&gt;move the uniqueness into a named composite constraint&lt;/a&gt; instead of rebuilding the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Constraint This ERD Cannot Express
&lt;/h2&gt;

&lt;p&gt;One honest gap, because it is the classic multi-tenant RBAC bug and the diagram will not warn you about it.&lt;/p&gt;

&lt;p&gt;Nothing in the foreign keys stops &lt;code&gt;members_roles&lt;/code&gt; from linking a member in tenant A to a role owned by tenant B. Both foreign keys are individually valid - the member exists, the role exists - and the row is a cross-tenant privilege escalation. Referential integrity checks that rows exist, not that they belong to the same tenant.&lt;/p&gt;

&lt;p&gt;There are three ways to close it, and the schema should pick one deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Carry &lt;code&gt;tenant_id&lt;/code&gt; into the junction&lt;/strong&gt; and use composite foreign keys, so the database itself refuses a mismatched pair. Strongest guarantee, at the cost of a wider key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce it in the application layer&lt;/strong&gt;, where role assignment already knows the acting member's tenant. Cheapest, and correct until someone writes a second code path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce it with row-level security&lt;/strong&gt;, if your database supports it and you are already scoping queries by tenant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you choose, notice that the ERD is what surfaced the question. The gap is visible precisely because &lt;a href="https://schemity.com/blog/you-cannot-judge-coupling-and-cohesion-from-the-same-view/" rel="noopener noreferrer"&gt;the auth tables and the account tables sit in different bounded contexts and you can see the lines crossing between them&lt;/a&gt; - &lt;code&gt;roles.tenant_id&lt;/code&gt; and &lt;code&gt;members_roles.member_id&lt;/code&gt; reaching into the account model from opposite directions, with nothing tying them together. When the coupling surface between two contexts is small enough to read in one glance, the missing constraint in it is findable.&lt;/p&gt;

&lt;p&gt;Seven tables, one asymmetry, and a routing table doing double duty as the source of truth for what your software can do. That is a complete role-based access control model for a multi-tenant system, and none of it needed a framework's opinion to get there.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>saas</category>
      <category>security</category>
    </item>
    <item>
      <title>Many-to-Many in an ERD Shouldn't Mean Hand-Building the Junction Table</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Wed, 22 Jul 2026 02:01:35 +0000</pubDate>
      <link>https://dev.to/tbson87/many-to-many-in-an-erd-shouldnt-mean-hand-building-the-junction-table-4fj1</link>
      <guid>https://dev.to/tbson87/many-to-many-in-an-erd-shouldnt-mean-hand-building-the-junction-table-4fj1</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Relational databases cannot store a many-to-many relationship directly, so most ERD tools make you hand-build the junction table - Microsoft documents six manual steps for one relationship. In Schemity, you drag between the two entities and pick N:N; the junction table appears with its foreign keys, composite primary key, and convention-following name already in place, as a real table you can rename and extend. One refinement: keep that composite primary key when both parents live in the same bounded context, but give a junction its own id plus a unique constraint over the pair when it bridges two contexts or grows a lifecycle of its own, like a subscription - so the pairing can recur and the contexts can split databases later without rewriting the table's identity.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A many-to-many relationship is one conceptual fact, and expressing one fact in your diagram should cost one gesture - not a hand-assembled third table with copied key columns and two carefully aimed relationship lines. The junction table is a mechanical consequence of the relationship, and mechanical consequences are exactly what a tool should produce for you.&lt;/p&gt;

&lt;p&gt;Most ERD tools disagree. Microsoft's documentation for the database diagram designer in SQL Server Management Studio lays out &lt;a href="https://learn.microsoft.com/en-us/ssms/visual-db-tools/map-many-to-many-relationships-visual-database-tools" rel="noopener noreferrer"&gt;six manual steps to map one many-to-many relationship&lt;/a&gt;: add the two tables, right-click to create a third, rename it in a dialog, copy the primary key columns from each parent into it, set the primary key to include all of the copied columns, then define a one-to-many relationship from each parent - making sure the junction lands on the "many" side both times. Six steps, five objects (three tables and two relationships), one fact.&lt;/p&gt;

&lt;p&gt;And this is not a legacy-tool quirk. dbdiagram.io - a DSL-first tool built by people who think hard about schema notation - had users &lt;a href="https://community.dbdiagram.io/t/support-many-to-many-relationship-in-dbml/1218" rel="noopener noreferrer"&gt;asking for native many-to-many support for years&lt;/a&gt; before the &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt; syntax shipped in June 2022. As one user put it in February of that year: "I have no idea why this is not implemented and it needs a vote, its basic ERD stuff." Until then, every book-author pairing meant writing the associative table out by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hand-built junction tables are where schemas quietly go wrong
&lt;/h2&gt;

&lt;p&gt;The six steps are tedious, but tedium is the smaller problem. Each step is also a chance to encode a mistake that the diagram will happily render as correct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Forget the composite key&lt;/strong&gt; and nothing stops the same pairing from being inserted twice. A &lt;code&gt;student_courses&lt;/code&gt; table without uniqueness over &lt;code&gt;(student_id, course_id)&lt;/code&gt; records the same enrollment as many times as your application bugs allow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aim a crow's foot the wrong way&lt;/strong&gt; and your junction sits on the "one" side of a relationship, which quietly turns the whole construction back into a 1:N.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name the keys inconsistently&lt;/strong&gt; - &lt;code&gt;studentId&lt;/code&gt; in one junction, &lt;code&gt;student_id&lt;/code&gt; in the next - and the schema drifts one hand-typed field at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are not beginner-only failures. They are what happens when a tool outsources a mechanical construction to a human, on the tenth junction table of a long modeling session. The difference between a &lt;a href="https://schemity.com/blog/you-have-been-reading-erd-notation-wrong" rel="noopener noreferrer"&gt;1:N and an N:N relationship&lt;/a&gt; is precisely the junction table and its composite key - get either wrong and the diagram claims a cardinality the schema does not enforce.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I create a many-to-many relationship in an ERD?
&lt;/h2&gt;

&lt;p&gt;Conceptually, the answer is always the same: a many-to-many relationship cannot hold a foreign key on either side alone, so you resolve it into a junction table - a third table carrying one foreign key per parent - which turns one N:N into two 1:N relationships. That resolution is fully determined the moment you decide the relationship is N:N. Nothing about it requires human judgment.&lt;/p&gt;

&lt;p&gt;So Schemity treats it as output, not input. Drag from one entity to the other and pick N:N in the &lt;a href="https://schemity.com/doc/relationships" rel="noopener noreferrer"&gt;relationship&lt;/a&gt; dialog, and the &lt;a href="https://schemity.com/doc/auto-junction-tables" rel="noopener noreferrer"&gt;junction table is created automatically&lt;/a&gt;: one foreign key field per parent, the two keys forming a composite primary key so each pairing is unique, and a name generated from your naming convention, like &lt;code&gt;students_courses&lt;/code&gt;. Prefer to place the table yourself? Draw two relationships into an empty entity and Schemity detects it as a junction table and does the same wiring. Foreign keys follow the snake_case or camelCase convention you set per connection, and land below the primary key instead of wherever the cursor happened to be.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Hand-built (SSMS-style)&lt;/th&gt;
&lt;th&gt;Schemity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Steps per relationship&lt;/td&gt;
&lt;td&gt;6 documented manual steps&lt;/td&gt;
&lt;td&gt;1 drag, pick N:N&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreign key columns&lt;/td&gt;
&lt;td&gt;Copied by hand from each parent&lt;/td&gt;
&lt;td&gt;Added automatically, convention-named&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Composite primary key&lt;/td&gt;
&lt;td&gt;You remember to set it&lt;/td&gt;
&lt;td&gt;Set automatically over both keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate pairings&lt;/td&gt;
&lt;td&gt;Possible if you forget uniqueness&lt;/td&gt;
&lt;td&gt;Blocked by the composite key from the start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Junction naming&lt;/td&gt;
&lt;td&gt;Typed in a dialog, per table&lt;/td&gt;
&lt;td&gt;Auto-generated, editable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The junction table is a real table, not a notation trick
&lt;/h2&gt;

&lt;p&gt;Some tools go to the opposite extreme and hide the junction entirely - a single N:N line with no table behind it. That reads nicely until the pairing needs data of its own, which it almost always eventually does: an enrollment gets a &lt;code&gt;grade&lt;/code&gt; and an &lt;code&gt;enrolled_at&lt;/code&gt;, a role-permission grant gets a &lt;code&gt;granted_by&lt;/code&gt;. A junction your tool refuses to show is a junction you cannot extend.&lt;/p&gt;

&lt;p&gt;Schemity's auto-created junction is an ordinary entity. Rename it, recolor it, add the attribute columns the relationship accrues, and when a pairing needs extra scoping, add a named &lt;a href="https://schemity.com/doc/check-constraints-composite-unique" rel="noopener noreferrer"&gt;composite unique constraint&lt;/a&gt; - each unique set gets its own colored U badge, so two overlapping rules on the same junction stay distinguishable. The full crow's foot notation on both lines keeps the two 1:N halves readable, with cardinality, ON DELETE, and ON UPDATE all configurable. You get the honesty of the physical schema and the convenience of the one-gesture shorthand - not a choice between them.&lt;/p&gt;

&lt;p&gt;That combination matters most in the schemas that lean hardest on junctions. A &lt;a href="https://schemity.com/blog/design-data-model-for-multi-tenant-rbac" rel="noopener noreferrer"&gt;multi-tenant RBAC design&lt;/a&gt; is essentially junction tables all the way down - &lt;a href="https://schemity.com/blog/design-roles-and-permissions-for-multi-tenant-rbac" rel="noopener noreferrer"&gt;members to roles, roles to permissions&lt;/a&gt; - and each one needs exactly the wiring described above. Building that model in a desktop ERD tool that constructs every junction correctly by default means the tenth one is as sound as the first, whether you are sketching offline on a laptop or working against a live development database. And because the auto-junction is derived from the relationship's cardinality rather than painted on, it obeys the same rule that keeps &lt;a href="https://schemity.com/blog/why-erd-tools-draw-1-1-relationships-as-1-n" rel="noopener noreferrer"&gt;1:1 relationships from being drawn as 1:N&lt;/a&gt;: the diagram only says what the constraints actually enforce.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should a junction table have a composite primary key vs its own id?
&lt;/h2&gt;

&lt;p&gt;There are two legitimate shapes for a junction table, and choosing between them is not a matter of taste - it is a matter of where the two parents live.&lt;/p&gt;

&lt;p&gt;The first shape is the one the auto-junction gives you: the two foreign keys &lt;em&gt;are&lt;/em&gt; the primary key. The pairing itself is the row's identity, uniqueness comes free from the key, and there is nothing extra to maintain. The second shape gives the junction its own surrogate &lt;code&gt;id&lt;/code&gt;, keeps the two foreign keys as ordinary fields, and enforces the pairing with a composite unique constraint over both. Same duplicate protection - a different answer to the question of what the row &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The rule that decides between them is the context boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Both parents in the same bounded context: take the composite primary key.&lt;/strong&gt; The link is an internal detail of one domain - nothing outside the context will ever address a link row directly, the two tables will live and move together, and the auto-created shape is exactly right. Drag, pick N:N, done. Maximum convenience, zero ceremony.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parents in two different contexts: give the junction its own id and a unique-together constraint.&lt;/strong&gt; A junction that bridges contexts is not an internal detail - it is the coupling point between two domains, and you cannot know how long those domains will keep sharing one database. If one context is ever extracted into its own service and schema, a foreign key cannot cross the database boundary anymore: the constraint gets dropped and the column degrades gracefully into a soft reference. When that column was an ordinary field guarded by a unique constraint, the split costs one small ALTER, and the row's own id - the thing audit records, events, and APIs have been pointing at - survives untouched. When that column was half of the &lt;em&gt;primary key&lt;/em&gt;, the split forces you to restructure the table's identity, and everything that referenced it, at the worst possible moment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a second hint that points to the surrogate shape, and it works even inside a single context: &lt;strong&gt;the junction has a lifecycle of its own.&lt;/strong&gt; Some link tables never stay bookkeeping. A subscription is, structurally, the junction between a customer and a plan - a customer subscribes to many plans, a plan serves many customers - yet nobody models it as a bare pairing, because a subscription is born, trials, goes past due, and gets canceled; Stripe's API gives the subscription object eight distinct lifecycle statuses, from &lt;code&gt;trialing&lt;/code&gt; to &lt;code&gt;canceled&lt;/code&gt;. An enrollment is the same story one industry over. The moment a link row starts changing state, two things quietly break the composite-primary-key shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Other tables start pointing at the link row.&lt;/strong&gt; Invoices reference the subscription, certificates reference the enrollment. With a surrogate id, each of those is one clean foreign key; with a composite primary key, every referencing table must carry both parent keys forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pairing stops being unique for all time.&lt;/strong&gt; A customer cancels and later resubscribes to the same plan; a student retakes the same course next term. Uniqueness becomes a scoped business rule - one &lt;em&gt;active&lt;/em&gt; subscription per customer-plan pair - which a named unique constraint can be evolved or re-scoped to express, and which a primary key can never become without rebuilding the table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the full heuristic: take the composite primary key while the junction is pure bookkeeping inside one bounded context, and reach for the surrogate id plus unique-together constraint when the junction crosses a context boundary &lt;em&gt;or&lt;/em&gt; the moment you catch it accruing a lifecycle. In practice the two signals travel together - the link tables that grow into real business entities are usually the very ones coupling two domains.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Composite primary key&lt;/th&gt;
&lt;th&gt;Own id + unique-together constraint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Row identity&lt;/td&gt;
&lt;td&gt;The pairing itself&lt;/td&gt;
&lt;td&gt;A surrogate id; the pairing is a named unique constraint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best when&lt;/td&gt;
&lt;td&gt;Both parents share one bounded context and the link is pure bookkeeping&lt;/td&gt;
&lt;td&gt;The junction bridges two contexts, or is itself a business entity with a lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other tables referencing the link row&lt;/td&gt;
&lt;td&gt;Must carry both parent keys as a composite foreign key&lt;/td&gt;
&lt;td&gt;Point at one clean id column&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;If the pairing can recur over time&lt;/td&gt;
&lt;td&gt;Impossible - the primary key forbids the second row&lt;/td&gt;
&lt;td&gt;Re-scope the unique constraint (for example, unique among active rows)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;If the contexts split databases&lt;/td&gt;
&lt;td&gt;Primary key must be restructured, references and all&lt;/td&gt;
&lt;td&gt;Drop one foreign key constraint; the id and the unique pair survive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In Schemity&lt;/td&gt;
&lt;td&gt;Drag between parents, pick N:N&lt;/td&gt;
&lt;td&gt;Create the entity, drag one relationship from each parent, add a composite unique constraint&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Schemity makes the second shape nearly as cheap as the first. Create the junction entity yourself - the active &lt;a href="https://schemity.com/doc/templates" rel="noopener noreferrer"&gt;entity template&lt;/a&gt; already gives it its &lt;code&gt;id&lt;/code&gt; primary key - then drag a relationship from each parent: both foreign keys land right below the primary key, named by your convention. Because the table now contains a field that is not a foreign key, the junction auto-detection stays out of the way and the surrogate shape is preserved; finish by spanning the two foreign keys with a named composite unique constraint, and its colored U badge announces the pairing rule on the diagram itself.&lt;/p&gt;

&lt;p&gt;The boundary that drives this decision is also visible in the tool. A context view is a saved, focused view of your schema that shows only a subset of entities and the relationships between them - the practical form of a bounded context ERD. Put each domain in its own &lt;a href="https://schemity.com/doc/context-views" rel="noopener noreferrer"&gt;context view&lt;/a&gt;, and every cross-context junction you model shows up on &lt;a href="https://schemity.com/blog/your-ddd-context-map-is-already-in-your-foreign-keys" rel="noopener noreferrer"&gt;the Context Map as a dependency arrow with its foreign keys counted&lt;/a&gt; - so the junctions that deserve the surrogate-key treatment are precisely the ones the map highlights.&lt;/p&gt;

&lt;p&gt;Your ERD tool knows what a many-to-many relationship implies. It should be the one doing the implying - and when you deliberately overrule the default shape at a context boundary, it should make that shape just as cheap to build.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>beginners</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why ERD Tools Draw Your 1:1 Relationship as 1:N</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Wed, 22 Jul 2026 02:00:50 +0000</pubDate>
      <link>https://dev.to/tbson87/why-erd-tools-draw-your-11-relationship-as-1n-3oag</link>
      <guid>https://dev.to/tbson87/why-erd-tools-draw-your-11-relationship-as-1n-3oag</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A 1:1 relationship in SQL is nothing but a foreign key with a unique constraint, so any tool that reads the key and ignores the constraint draws it as 1:N - a bug reported against MySQL Workbench in 2009 and against dbdiagram in 2025. Schemity derives cardinality from the constraint itself: toggle the foreign key's uniqueness and the relationship toggles between 1:1 and 1:N, and composite unique sets get named, colored U badges instead of one ambiguous marker.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A one-to-one relationship in SQL is not a special kind of line. It is a foreign key with a unique constraint - and any tool that reads the foreign key but ignores the constraint will draw your 1:1 as 1:N.&lt;/p&gt;

&lt;p&gt;This is not a hypothetical failure. In March 2009, a user filed &lt;a href="https://bugs.mysql.com/bug.php?id=43920" rel="noopener noreferrer"&gt;bug #43920 against MySQL Workbench 5.0.30&lt;/a&gt;: reverse engineering a script turned every 1:1 relationship into 1:N, which they called "a big problem if you use JPA and you generate the entities with some tool." The bug was closed as &lt;em&gt;Not a Bug&lt;/em&gt;, with a developer explaining that Workbench's 1:1 relationship button "is just for the graphic not for the SQL" - the button changes the picture without emitting the unique constraint that would make the picture true. Users kept disputing that resolution in the comments from 2010 all the way to 2024.&lt;/p&gt;

&lt;p&gt;Sixteen years after the original report, in May 2025, a dbdiagram user hit &lt;a href="https://community.dbdiagram.io/t/a-foreign-key-which-has-a-unique-constraint-should-be-reported-as-1-1-relation/4697" rel="noopener noreferrer"&gt;the same misreading from the other direction&lt;/a&gt;: they imported PostgreSQL SQL in which a column carried both a &lt;code&gt;UNIQUE&lt;/code&gt; constraint and a &lt;code&gt;FOREIGN KEY&lt;/code&gt;, and wrote, "By reading the SQL, I would expect the system to report the relation as 1:1 relation, but instead I see the system reporting it as a 1:n relation." Different tool, different decade, same bug - because both tools treat cardinality as something separate from the constraints that define it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does my ERD show a one-to-one relationship as one-to-many?
&lt;/h2&gt;

&lt;p&gt;Because your tool derives the relationship from the foreign key alone and never checks whether that key is unique. There are two versions of the failure, and the two reports above are one of each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On the way in&lt;/strong&gt;, the importer sees &lt;code&gt;FOREIGN KEY&lt;/code&gt; and draws a crow's foot. The &lt;code&gt;UNIQUE&lt;/code&gt; constraint sitting on the same column may well be parsed and stored, but it never reaches the code that picks the line ends. The schema says 1:1; the diagram says 1:N.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On the way out&lt;/strong&gt;, the editor stores "1:1" as a drawing attribute. You pick it from a dropdown, the line ends change, and the generated SQL contains no unique constraint at all. The diagram says 1:1; the schema says 1:N.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, the diagram and the database now disagree about a fact as basic as "can a user have two profiles?" - and whichever artifact your teammate happens to read determines what they believe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Uniqueness is the cardinality, not a decoration
&lt;/h2&gt;

&lt;p&gt;Relational databases have no &lt;code&gt;CARDINALITY&lt;/code&gt; keyword. The relationship type is never stored anywhere as a fact of its own - it is a consequence of which constraints exist:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the schema actually contains&lt;/th&gt;
&lt;th&gt;What the diagram must draw&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Foreign key, no unique constraint&lt;/td&gt;
&lt;td&gt;1:N - crow's foot on the child side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreign key with a unique constraint or unique index&lt;/td&gt;
&lt;td&gt;1:1 - a single bar on both sides&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreign key inside a composite unique constraint spanning other columns&lt;/td&gt;
&lt;td&gt;Still 1:N - the &lt;em&gt;combination&lt;/em&gt; is unique, the key alone is not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two foreign keys in a &lt;a href="https://schemity.com/blog/many-to-many-shouldnt-mean-hand-building-the-junction-table" rel="noopener noreferrer"&gt;junction table&lt;/a&gt;, unique together&lt;/td&gt;
&lt;td&gt;N:N, resolved into two 1:N relationships&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row is the one that trips up even tools that do check uniqueness: &lt;code&gt;UNIQUE (tenant_id, email)&lt;/code&gt; does not make &lt;code&gt;tenant_id&lt;/code&gt; unique, so the relationship it participates in stays one-to-many. A tool cannot get this right by pattern-matching column names. It has to model unique constraints as first-class objects with member lists - which is exactly what most diagram tools skip.&lt;/p&gt;

&lt;p&gt;This is the same lesson as reading &lt;a href="https://schemity.com/blog/you-have-been-reading-erd-notation-wrong" rel="noopener noreferrer"&gt;crow's foot notation against the SQL it implies&lt;/a&gt;: the notation is a rendering of constraints, not an independent layer of truth. When a tool lets the rendering float free of the constraints, the rendering lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  A relationship type you toggle, not repaint
&lt;/h2&gt;

&lt;p&gt;Schemity makes the two facts one fact. A &lt;a href="https://schemity.com/doc/relationships" rel="noopener noreferrer"&gt;relationship&lt;/a&gt; between two entities is 1:1 precisely when its foreign key is unique - so you toggle between 1:1 and 1:N by toggling the foreign key's uniqueness, not by repainting line ends. There is no separate drawing attribute to fall out of sync, which means the Workbench failure mode - a 1:1 picture backed by 1:N SQL - cannot be drawn at all.&lt;/p&gt;

&lt;p&gt;The same holds on the way in. When you &lt;a href="https://schemity.com/doc/reverse-engineer-database" rel="noopener noreferrer"&gt;reverse engineer a PostgreSQL, MySQL, or SQL Server schema into an ERD&lt;/a&gt;, unique constraints and unique indexes are read alongside the foreign keys, so a unique foreign key arrives as the 1:1 relationship it is. As a desktop ERD tool, Schemity does all of this locally - the schema you are reverse engineering never leaves your machine, which matters when the database you are documenting is the one you are not allowed to paste into a web app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composite unique constraints deserve names and colors, not one shared badge
&lt;/h2&gt;

&lt;p&gt;Single-column uniqueness is the easy half. The harder half is the multi-column rule - "email is unique &lt;em&gt;per tenant&lt;/em&gt;", "line numbers are unique &lt;em&gt;within an order&lt;/em&gt;" - and it has been underserved in diagrams for a long time. Back in 2010, a Visual Paradigm Database Architect 5.2 user asked the forum how to see which columns belonged to a multi-column unique constraint; the patch that answered the request showed a single "U" on any column involved in &lt;em&gt;any&lt;/em&gt; unique constraint. Better than nothing, but if a table carries two overlapping constraints, one undifferentiated marker cannot tell you which columns group together.&lt;/p&gt;

&lt;p&gt;Schemity treats ERD composite unique constraints as first-class objects: each one has a member list, an optional name, and a color, and every involved field wears an underlined U badge in that color - so two different unique sets on the same table are distinguishable at a glance, and the &lt;a href="https://schemity.com/doc/check-constraints-composite-unique" rel="noopener noreferrer"&gt;composite unique constraint&lt;/a&gt; reads as the business rule it is. The entity footer keeps a running tally of fields, indexes, check constraints, and unique rules, so a table's rule density is visible before you open anything.&lt;/p&gt;

&lt;p&gt;This is the constraint that carries most &lt;a href="https://schemity.com/blog/design-data-model-for-multi-tenant-rbac" rel="noopener noreferrer"&gt;multi-tenant RBAC schema&lt;/a&gt; designs, where &lt;code&gt;UNIQUE (tenant_id, email)&lt;/code&gt; is the difference between "email is a login" and "email is a login within one tenant." A diagram that cannot show which columns form the pair is hiding the tenancy rule itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagram should not be able to disagree with the schema
&lt;/h2&gt;

&lt;p&gt;The Workbench developer was honest about the design: the 1:1 button was "just for the graphic." That is the root problem, and it is not specific to one tool - any ERD tool that stores cardinality as decoration will eventually show you a relationship the database does not enforce, the same way most tools &lt;a href="https://schemity.com/blog/your-erd-is-hiding-your-domain-model" rel="noopener noreferrer"&gt;hide the check constraints that encode your domain rules&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;An offline ERD tool built for software engineers should work the way the database works: uniqueness is the cardinality, constraints are objects with identity, and the crow's foot at the end of a line is derived from facts, not chosen from a dropdown. When the diagram physically cannot say something the schema does not say, you stop having to ask which one is telling the truth.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>mysql</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Your DDD Context Map Is Already in Your Foreign Keys</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Wed, 22 Jul 2026 02:00:14 +0000</pubDate>
      <link>https://dev.to/tbson87/your-ddd-context-map-is-already-in-your-foreign-keys-5fim</link>
      <guid>https://dev.to/tbson87/your-ddd-context-map-is-already-in-your-foreign-keys-5fim</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Hand-drawn context maps go stale because nothing ties them to the schema, and cross-context coupling stays invisible until an extraction fails. Schemity's Context Map derives the map from your foreign keys: each context view becomes one node, arrows show dependency direction with a foreign key count, and double-clicking an arrow lists the exact foreign keys behind it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You do not need to draw your context map, because your schema already contains it: every foreign key that crosses a context boundary is one edge of the map. Schemity's Context Map renders that map for you - each context view collapses into a single node, and the arrows between nodes are derived from the actual foreign keys in the diagram, not from anyone's memory of them.&lt;/p&gt;

&lt;p&gt;The reason this matters is what teams do today instead. The context map - the DDD picture of which bounded contexts exist and who depends on whom - is usually a drawing. As one practitioner report on Context Mapper puts it, DDD models "are typically developed on a whiteboard or with Post-it notes," and &lt;a href="https://ozimmer.ch/modeling/2022/11/23/ContextMapperInsights.html" rel="noopener noreferrer"&gt;few tools support maintaining them&lt;/a&gt; - which is why a dedicated DSL for writing context maps by hand has existed since 2018. A whiteboard photo is stale the day after the workshop. A hand-written DSL file is better, but it is still a second artifact: someone has to notice that the schema changed and go edit the map to match. Nothing enforces that, so nobody does it, and the map quietly stops being true.&lt;/p&gt;

&lt;p&gt;Meanwhile the coupling the map was supposed to expose keeps accumulating where nobody is looking. One backend engineering account describes how a single innocent &lt;code&gt;JOIN payments.transactions ON orders.payment_id = payments.id&lt;/code&gt; "ends up becoming the coupling hell," and recounts teams &lt;a href="https://backendengineeringadventures.substack.com/p/database-design-for-modular-monoliths" rel="noopener noreferrer"&gt;struggling for 3+ years to break core business logic out of a big database&lt;/a&gt; - because by the time anyone tried to extract a module, months of cross-module foreign keys had piled up unseen. The pain is not that the dependencies exist. It is that no view of the system showed them at the scale where they could be judged.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I see the dependencies between bounded contexts in my database?
&lt;/h2&gt;

&lt;p&gt;Derive the picture from the schema instead of drawing it. In Schemity, an offline ERD tool built for software engineers, that is what the &lt;a href="https://schemity.com/doc/context-map" rel="noopener noreferrer"&gt;Context Map&lt;/a&gt; does.&lt;/p&gt;

&lt;p&gt;A context view is a saved, focused view of your schema that shows only a subset of entities and the relationships between them - the natural home of a &lt;strong&gt;bounded context ERD&lt;/strong&gt;. The Context Map is the level above: a view where each context view is rendered as a single node, and an arrow is drawn from context A to context B whenever entities in A hold foreign keys into entities in B. Each arrow carries a badge with the number of foreign keys flowing in that direction.&lt;/p&gt;

&lt;p&gt;There is no separate modeling step to reach it. The contexts are the views you already saved while working on the schema, and the map is one click away from the list of them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8pvnxpknhk8tbhwkpy85.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8pvnxpknhk8tbhwkpy85.webp" alt="The Context Views panel listing the saved context views in a schema, with the Context Map button in the panel header" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In my own multi-tenant auth schema - 56 entities, 71 relations on the main view - the Context Map collapses to three nodes: &lt;code&gt;Account View&lt;/code&gt;, &lt;code&gt;Auth View&lt;/code&gt;, &lt;code&gt;Segment View&lt;/code&gt;. Between &lt;code&gt;Auth View&lt;/code&gt; and &lt;code&gt;Account View&lt;/code&gt; there are two arrows, one per direction: 1 foreign key flowing out of &lt;code&gt;Account View&lt;/code&gt;, 5 flowing back into it. That asymmetry is the design in one glance - identity lives in &lt;code&gt;account&lt;/code&gt;, and &lt;code&gt;auth&lt;/code&gt; consumes it. On a whiteboard map I would have drawn one vague line labeled "uses." The derived map tells me the direction and the weight, and it can never disagree with the schema, because it is computed from it.&lt;/p&gt;

&lt;p&gt;Each context view can carry its own color, and the Context Map inherits it: the node header and every outgoing dependency arrow take the context's color, the same way &lt;a href="https://schemity.com/blog/you-shouldnt-have-to-guess-which-line-goes-where" rel="noopener noreferrer"&gt;entity colors propagate to relationship lines&lt;/a&gt; one level down. Contexts stay recognizable at both zoom levels, and clicking a context highlights all of its dependency arrows, so inspecting one context on a busy map never means following lines by eye. Every arrow begins with a circle at its source end - the same discipline as line hops on the main canvas: where a line starts must never be a guess, even when it passes through another context on its way. On a map with many nodes, &lt;a href="https://schemity.com/blog/finding-a-column-shouldnt-take-a-sql-query" rel="noopener noreferrer"&gt;fuzzy search&lt;/a&gt; works here too - type a fragment of a context's name and the map focuses and highlights it - and the footer keeps the two numbers that describe the architecture in view: how many contexts, how many dependencies.&lt;/p&gt;

&lt;p&gt;Three nodes is a small schema. Here is the same view on a larger one - a CMS whose footer reads 12 contexts and 20 dependencies between them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fschemity.com%2Fimages%2Fblog%2Fyour-ddd-context-map-is-already-in-your-foreign-keys%2Fcontext-map.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fschemity.com%2Fimages%2Fblog%2Fyour-ddd-context-map-is-already-in-your-foreign-keys%2Fcontext-map.svg" alt="A Context Map of twelve contexts including account, Auth, catalog, learning, and targeting, with twenty dependency arrows between them, each carrying a badge with its foreign key count" width="1846" height="1527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At that size the whole architecture still fits on one screen, which is the point: twelve contexts and twenty dependencies is a paragraph of prose or an unreadable wall of 60 tables, but it is a single readable picture. Finding one context in it is a few keystrokes rather than a hunt.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq70gcmbreqkjr2soybde.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq70gcmbreqkjr2soybde.webp" alt="The context search panel open over the Context Map, listing each context with its entity count" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The arrows carry one more signal: shape. A straight arrow is a one-way dependency. A curved arrow means the dependency runs both ways - a circular dependency between two contexts. That is exactly what the curved pair between &lt;code&gt;Auth View&lt;/code&gt; and &lt;code&gt;Account View&lt;/code&gt; admits about my schema: those two contexts lean on each other, and neither could be extracted without touching the other. Scanning a schema for context-level cycles used to be a query-writing exercise; on the Context Map it is a visual scan for curves.&lt;/p&gt;

&lt;p&gt;Curves catch the two-context case. For cycles that hide across three or more contexts - A depends on C, C on B, B back on A - every individual arrow is straight, so no shape can flag them. That is a graph question, and the map gives you two ways to walk the graph. The manual way turns click-to-highlight into a traversal: click the context you are checking, follow its highlighted arrows to each target, and re-highlight round by round - if a round ever lights an arrow back to where you started, you have found an indirect cycle. One click is one round of that walk: the selected context keeps its arrows and their foreign key counts in full color while every other dependency drops back, so you are reading four edges instead of twenty.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvbidwaq6rhfiu3vzgzsa.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvbidwaq6rhfiu3vzgzsa.webp" alt="The learning context selected on the Context Map, its outgoing arrows highlighted with their foreign key counts while every other dependency fades into the background" width="800" height="556"&gt;&lt;/a&gt; Or ask the built-in &lt;a href="https://schemity.com/doc/ai-assisted-design" rel="noopener noreferrer"&gt;AI chat, which analyzes the Context Map&lt;/a&gt; and names the cycles directly - the faster route when the map has many contexts. It will also re-arrange the map's layout on request, so tidying the architecture picture is one sentence, not a drag session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every arrow is backed by the exact foreign keys that create it
&lt;/h2&gt;

&lt;p&gt;A high-level map you cannot interrogate is just a prettier whiteboard. So every arrow on the Context Map answers for itself: double-click it, and a panel lists each entity relation behind the dependency - source field, target entity, and the real constraint name.&lt;/p&gt;

&lt;p&gt;The 5-key arrow from &lt;code&gt;Auth View&lt;/code&gt; into &lt;code&gt;Account View&lt;/code&gt; expands into &lt;code&gt;member_roles.member_id -&amp;gt; members&lt;/code&gt;, &lt;code&gt;otps.member_id -&amp;gt; members&lt;/code&gt;, &lt;code&gt;otps.tenant_id -&amp;gt; tenants&lt;/code&gt;, &lt;code&gt;roles.tenant_id -&amp;gt; tenants&lt;/code&gt;, and &lt;code&gt;sso_configs.tenant_id -&amp;gt; tenants&lt;/code&gt;. Five keys, two shapes: a member identity or a tenant identity flowing inward.&lt;/p&gt;

&lt;p&gt;The badge is a promise the panel keeps: one row per foreign key, always. On the CMS map above, the &lt;code&gt;3&lt;/code&gt; on &lt;code&gt;progress -&amp;gt; learning&lt;/code&gt; opens into three relations - &lt;code&gt;trainee_badges.trainee_id&lt;/code&gt;, &lt;code&gt;trainee_reward_points.trainee_id&lt;/code&gt;, and &lt;code&gt;trainee_stage_points.trainee_id&lt;/code&gt;, each pointing at &lt;code&gt;trainees&lt;/code&gt;, each with its real constraint name attached.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flnqx8pormttkl3wt2vvo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flnqx8pormttkl3wt2vvo.webp" alt="The dependency detail panel for the progress to learning arrow, listing three entity relations with their source fields, target entity, and constraint names" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the difference between "progress depends on learning" and a work list. Three columns, three constraints to drop or replace - and the shape of the coupling is legible too: all three point at the same table, so this dependency is one concept borrowed three times, not three separate entanglements.&lt;/p&gt;

&lt;p&gt;The nodes answer for themselves the same way. A context that carries a markdown description shows a file icon on its Context Map node, opening the rendered description in place - so &lt;a href="https://schemity.com/blog/the-data-dictionary-should-live-in-the-erd" rel="noopener noreferrer"&gt;the data dictionary that lives inside the ERD&lt;/a&gt; surfaces at the architecture scale too: what a context &lt;em&gt;is for&lt;/em&gt; sits one click from the arrows that show what it &lt;em&gt;depends on&lt;/em&gt;. This is where a boundary stops being a shape and starts being a rule: which tables the context owns, which it merely borrows as a scope anchor, and what it deliberately excludes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0sirsrw93avabwual86r.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0sirsrw93avabwual86r.webp" alt="The markdown description modal for the Auth context, opened from the file icon on its Context Map node, describing what the view covers and its boundary rules" width="800" height="556"&gt;&lt;/a&gt; That is a coupling surface you can defend in a design review - or the itemized work list you hand to whoever extracts the module. The "3+ years of untangling" story above is what happens when that list is assembled &lt;em&gt;after&lt;/em&gt; the extraction decision instead of being one double-click away the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a derived context map beats a drawn one
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Drawn map (whiteboard, Miro, DSL)&lt;/th&gt;
&lt;th&gt;Schemity Context Map&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Source of the edges&lt;/td&gt;
&lt;td&gt;Someone's memory of the schema&lt;/td&gt;
&lt;td&gt;The foreign keys themselves&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When the schema changes&lt;/td&gt;
&lt;td&gt;Someone must notice and redraw&lt;/td&gt;
&lt;td&gt;The map is derived, so it follows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependency weight&lt;/td&gt;
&lt;td&gt;Not shown, or guessed&lt;/td&gt;
&lt;td&gt;Foreign key count per direction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drill-down&lt;/td&gt;
&lt;td&gt;None - ask the person who drew it&lt;/td&gt;
&lt;td&gt;Double-click lists every FK and constraint name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direction of dependency&lt;/td&gt;
&lt;td&gt;Often a line with no arrowhead&lt;/td&gt;
&lt;td&gt;Always directional, one arrow per direction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Circular dependencies&lt;/td&gt;
&lt;td&gt;Discovered during extraction&lt;/td&gt;
&lt;td&gt;Curved arrows flag every cycle at a glance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The left column is not a strawman; it is the state of the art the Context Mapper authors describe. The right column is only possible because in Schemity the contexts and the schema live in the same file: the &lt;a href="https://schemity.com/doc/single-source-of-truth" rel="noopener noreferrer"&gt;main view is the single source of truth&lt;/a&gt;, context views are perspectives on it, and the Context Map is derived from both. There is no moment where the map and the schema can drift, because the map is not stored - it is computed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third scale: coupling between contexts
&lt;/h2&gt;

&lt;p&gt;I wrote before that &lt;a href="https://schemity.com/blog/you-cannot-judge-coupling-and-cohesion-from-the-same-view" rel="noopener noreferrer"&gt;you cannot judge coupling and cohesion from the same view&lt;/a&gt; - the main view answers coupling, a context view answers cohesion. The Context Map completes that instrument set with a third scale. The main view shows you &lt;em&gt;which entities&lt;/em&gt; couple across borders; the Context Map shows you &lt;em&gt;how much&lt;/em&gt; and &lt;em&gt;in which direction&lt;/em&gt;, with the entities abstracted away entirely. And inside a context view, &lt;a href="https://schemity.com/blog/a-view-owes-you-back-the-context-it-removes" rel="noopener noreferrer"&gt;the orange boundary dot&lt;/a&gt; tells you an entity reaches outside the view - the Context Map is where that reach is finally analyzed rather than merely flagged.&lt;/p&gt;

&lt;p&gt;This is the zoom ladder domain-driven design database schema work needs: a &lt;a href="https://schemity.com/doc/context-views" rel="noopener noreferrer"&gt;database context view&lt;/a&gt; to read each bounded context without the noise of &lt;a href="https://schemity.com/blog/you-dont-need-a-diagram-of-all-800-tables" rel="noopener noreferrer"&gt;all 800 tables&lt;/a&gt;, the main view to see every entity and edge at once, and the Context Map to see the architecture - contexts and dependencies, nothing else. All three are ERD perspectives on one set of entities in one local JSON file, in a desktop ERD tool that works entirely offline - so the context map you show in the architecture review is not a hopeful drawing. It is the schema, zoomed out.&lt;/p&gt;

&lt;p&gt;And it travels. The Context Map exports as JPG, PNG, or &lt;a href="https://schemity.com/blog/why-your-erd-export-turns-blurry" rel="noopener noreferrer"&gt;sharp-at-every-zoom SVG&lt;/a&gt;, or as a Mermaid diagram that GitHub, GitLab, Notion, and Obsidian render natively - so the derived architecture picture can sit in the README of the very repo whose modules it describes, regenerated whenever the schema moves.&lt;/p&gt;

</description>
      <category>database</category>
      <category>architecture</category>
      <category>ddd</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
