<?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>Column Comments in PostgreSQL and MySQL: How to Document Columns Without a Migration</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Fri, 21 Aug 2026 03:21:45 +0000</pubDate>
      <link>https://dev.to/tbson87/column-comments-in-postgresql-and-mysql-how-to-document-columns-without-a-migration-2no0</link>
      <guid>https://dev.to/tbson87/column-comments-in-postgresql-and-mysql-how-to-document-columns-without-a-migration-2no0</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 database has a built-in place to document a column - COMMENT ON COLUMN in PostgreSQL, the COMMENT attribute in MySQL - and almost nobody fills it in, because a sentence of prose has to travel the same path as a schema change: a migration file, a review, a deploy. Schemity keeps field descriptions in the diagram instead, where editing one generates no SQL, reads existing database comments in on import, and exports the result as a data dictionary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can document a database column without touching the database: write the description in the model rather than in the schema. That sounds like a dodge until you price the alternative. The database's own mechanism for column documentation, &lt;code&gt;COMMENT ON COLUMN&lt;/code&gt; in PostgreSQL and the &lt;code&gt;COMMENT&lt;/code&gt; attribute in MySQL, sends a sentence of prose down exactly the same path as a change to how data is stored - a migration file, a code review, an approval, a deploy window - and on MySQL it does something worse than that. Schemity keeps field descriptions in the diagram, where editing one produces no SQL at all.&lt;/p&gt;

&lt;p&gt;This is why so many production schemas have thousands of columns and almost no comments. Not because nobody wanted to write them. Because writing one costs a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I document a database column without running a migration?
&lt;/h2&gt;

&lt;p&gt;Keep the description in the model rather than in the storage engine. A field description is a fact about what the column means to your team; it changes no type, no constraint, no index, and nothing about what the database will accept. When it lives in the diagram, editing it is like editing a comment in a code file: you change it, review it in the same pull request as everything else, and nothing has to run against production for it to take effect.&lt;/p&gt;

&lt;p&gt;The moment that description is a column comment, it stops being prose and becomes DDL. Now it needs a migration file, and the migration needs a reviewer, and the reviewer is looking at an &lt;code&gt;ALTER TABLE&lt;/code&gt; against a live table. Everyone in that chain is correct to be careful, which is the problem: the care is proportionate to a schema change, and this is not one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a column comment is priced like a schema change
&lt;/h2&gt;

&lt;p&gt;PostgreSQL gets this closest to right. &lt;code&gt;COMMENT ON COLUMN invoices.voided_at IS 'set when finance reverses an invoice'&lt;/code&gt; is a standalone statement that touches only the catalog. It is still a migration in every workflow where migrations own schema changes, but it is a cheap and safe one.&lt;/p&gt;

&lt;p&gt;MySQL has no equivalent. The comment is an attribute inside the column definition, so changing it means &lt;code&gt;ALTER TABLE ... MODIFY&lt;/code&gt;, and &lt;a href="https://dev.mysql.com/doc/refman/8.0/en/alter-table.html" rel="noopener noreferrer"&gt;MySQL's own manual&lt;/a&gt; states the trap plainly: "Attributes present in the original definition but not specified for the new definition are not carried forward." The manual's example is a column defined as &lt;code&gt;INT UNSIGNED DEFAULT 1 COMMENT 'my column'&lt;/code&gt;, modified with the intention of changing only the type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt; &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="n"&gt;col1&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;In the original definition&lt;/th&gt;
&lt;th&gt;After that statement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Data type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BIGINT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNSIGNED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;td&gt;dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEFAULT 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;td&gt;dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COMMENT 'my column'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;td&gt;dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three attributes vanish and the statement is perfectly valid. There is no error, no warning, and no comment-only syntax to reach for. To attach one sentence of documentation to a MySQL column, the migration has to restate the column's entire definition correctly, which means the documentation change is now capable of altering how data is stored. That is the reverse of what anyone wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why ORMs keep declining to support column comments
&lt;/h2&gt;

&lt;p&gt;The obvious escape is to let the ORM manage comments alongside everything else, and the ORMs have been declining for years. Drizzle has three separate open requests for it - issues 886, 1840 and &lt;a href="https://github.com/drizzle-team/drizzle-orm/issues/5203" rel="noopener noreferrer"&gt;5203&lt;/a&gt;, the last opened on 1 January 2026 - and the newest one argues from an angle that did not exist when the first was filed: "In modern projects, database comments are no longer only for humans - they are increasingly important machine-readable context for AI-powered tooling." node-db-migrate's request, issue 558, has been open since March 2018 carrying the labels "Nice to have" and "Not Planned". Doctrine's migrations have their own long-running report of comments disappearing from generated migrations.&lt;/p&gt;

&lt;p&gt;None of these maintainers are wrong. From inside a migration tool, column comments genuinely are niche: they are the only part of a column definition that no query result depends on. The pattern that emerges from eight years of open issues is not neglect, it is a category error. Documentation keeps being filed as a schema feature, gets ranked against schema features, and loses every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a field description belongs instead
&lt;/h2&gt;

&lt;p&gt;In Schemity, &lt;a href="https://schemity.com/doc/tables-and-fields/" rel="noopener noreferrer"&gt;a field carries a description of its own&lt;/a&gt;, the same way entities and legends already &lt;a href="https://schemity.com/doc/legends-and-annotations/" rel="noopener noreferrer"&gt;carry markdown descriptions&lt;/a&gt;. It is a text box in the field editor, next to the type and the default, and the sentence it holds is the one a MySQL migration would have had to restate a whole column definition to attach.&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%2Fd4dm1yv0o09xdcos4p2i.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%2Fd4dm1yv0o09xdcos4p2i.webp" alt="The field editor for articles.content in Schemity, with the Description box reading: String if we choose HTML WYSIWYG or Markdown editor. Use JSON if we choose structure content like Editor.js - alongside the field name, TEXT type, BLANK default and the PK, Unique and Nullable checkboxes" width="799" height="543"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That description is the kind nobody ever writes into a column comment, because it is the reasoning behind a type choice rather than a definition of the column, and it would need a deploy. It also answers the question a reader of &lt;code&gt;content TEXT&lt;/code&gt; actually has.&lt;/p&gt;

&lt;p&gt;A documented field is then visible without opening anything: a bar on the leading edge of the row marks any field that has one, so which parts of a table are documented is a glance down a column rather than an audit, and the bar is drawn into SVG exports too.&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%2Fgelyk1x9i3weww47b9m0.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%2Fgelyk1x9i3weww47b9m0.webp" alt="The articles entity on the Schemity canvas, nine fields listed, where only the content row carries a short pale bar on its leading edge marking it as the one field with a description" width="799" height="543"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One marked row out of nine is the honest picture of most schemas, and it is readable at a glance precisely because the mark is absence-shaped: you are looking for the fields that have no bar.&lt;/p&gt;

&lt;p&gt;The important property is what does not happen. Descriptions live in the diagram's &lt;a href="https://schemity.com/doc/json-storage-format/" rel="noopener noreferrer"&gt;JSON file&lt;/a&gt; and never reach the database, so writing one produces no migration to review and no statement to run. This was a deliberate reversal on our side rather than a design we got right first time. Schemity used to write descriptions back as column comments, and that produced exactly the migration described above - on MySQL, the worst-shaped statement in the whole product, restating an entire column definition from the diagram just to attach a sentence. It also lost work: because comments came back from the database on every &lt;a href="https://schemity.com/doc/resync-database/" rel="noopener noreferrer"&gt;re-sync&lt;/a&gt;, a description could be silently overwritten, invisibly on PostgreSQL and MySQL and permanently on SQL Server and SQLite.&lt;/p&gt;

&lt;p&gt;Reading still goes one way. Importing an already documented schema arrives documented, because comments that exist in the database are read in. They are simply never written back.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the comment really does belong in the database
&lt;/h2&gt;

&lt;p&gt;There is a real case on the other side, and the Drizzle issue names it: if the reader is a program that introspects the live database - an agent connecting to a schema it has never seen, a catalog crawler, a BI tool reading the information schema - then the comment has to be in the database, because that is the only place the reader looks. A description in a diagram file it cannot open is worth nothing to it.&lt;/p&gt;

&lt;p&gt;If that is your goal, write the comments as migrations and treat them as schema changes, deliberately. What you should not do is adopt that cost by accident for documentation that only people will ever read, which is the situation almost every team is actually in.&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;Comment in the database&lt;/th&gt;
&lt;th&gt;Description in the model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read by&lt;/td&gt;
&lt;td&gt;Anything that introspects the catalog&lt;/td&gt;
&lt;td&gt;People, and any export you generate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost of an edit&lt;/td&gt;
&lt;td&gt;Migration, review, deploy&lt;/td&gt;
&lt;td&gt;Save the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL cost of an edit&lt;/td&gt;
&lt;td&gt;Restating the full column definition&lt;/td&gt;
&lt;td&gt;The same as any other engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survives a schema refresh&lt;/td&gt;
&lt;td&gt;Yes, it is the source&lt;/td&gt;
&lt;td&gt;Yes, it is not overwritten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reviewed in the pull request&lt;/td&gt;
&lt;td&gt;As DDL&lt;/td&gt;
&lt;td&gt;As a diff in the diagram JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Getting the documentation to the people who need it
&lt;/h2&gt;

&lt;p&gt;A description nobody can reach is not documentation, which is the fair objection to keeping it out of the database. The answer is export rather than storage: the diagram exports as a data dictionary in HTML, Markdown and Excel, covering every column with its type, key role, nullability, default and description alongside the constraints and relationships. The export follows the active view, and a context view is a saved, focused subset of the schema showing only some entities and the relationships between them, so &lt;a href="https://schemity.com/doc/context-views/" rel="noopener noreferrer"&gt;documenting one context&lt;/a&gt; produces a document about that context rather than the whole database.&lt;/p&gt;

&lt;p&gt;It also ends by counting what is not written down yet: how many entities and fields carry a description, and the names of those that do not. It states the numbers and stops there, which is the only honest way to report on documentation coverage.&lt;/p&gt;

&lt;p&gt;That closing count is the part that changes behaviour, because the reason columns go undocumented was never that people did not care. It was that &lt;a href="https://schemity.com/blog/the-data-dictionary-should-live-in-the-erd/" rel="noopener noreferrer"&gt;the cheapest place to write it down&lt;/a&gt; had a deploy attached, so the note went into a wiki page instead and drifted. Take the deploy off the description and the note goes where the schema is - and then &lt;a href="https://schemity.com/blog/export-database-data-dictionary/" rel="noopener noreferrer"&gt;the data dictionary is a document you generate&lt;/a&gt; rather than one you maintain.&lt;/p&gt;

</description>
      <category>database</category>
      <category>documentation</category>
      <category>sql</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Circular Foreign Keys: Why the First Row Cannot Be Inserted</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Wed, 19 Aug 2026 01:48:01 +0000</pubDate>
      <link>https://dev.to/tbson87/circular-foreign-keys-why-the-first-row-cannot-be-inserted-30h5</link>
      <guid>https://dev.to/tbson87/circular-foreign-keys-why-the-first-row-cannot-be-inserted-30h5</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; When foreign keys form a cycle and every column in it is NOT NULL, each insert needs a row that does not exist yet, so an empty database can never take its first row. The cycle is a property of the whole graph rather than of any one relationship, so nobody spots it by looking at the diagram - it surfaces at seed time on a fresh environment. Schemity's fk-cycle-all-not-null lint rule computes it from the open ERD offline and marks the entities involved in the margin, and the fix is a modelling decision: make one side nullable, defer the check on an engine that can, or move the reference into a third table.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A cycle of foreign keys is legal to create and impossible to populate. If &lt;code&gt;departments.manager_id&lt;/code&gt; is &lt;code&gt;NOT NULL&lt;/code&gt; and references &lt;code&gt;employees&lt;/code&gt;, and &lt;code&gt;employees.department_id&lt;/code&gt; is &lt;code&gt;NOT NULL&lt;/code&gt; and references &lt;code&gt;departments&lt;/code&gt;, the schema is valid, the diagram is tidy, the migration applies cleanly, and the database will never accept a single row. Each insert needs a row that does not exist yet.&lt;/p&gt;

&lt;p&gt;That failure has a specific arrival time, and it is not review. It arrives the first time somebody points the seed script at an empty database: a new staging environment, a contributor's local machine, a fresh tenant, the disaster-recovery rehearsal. Production is fine, because production was populated years ago by whoever fought through it once. The defect sat in the schema the whole time and only the empty case exposes it.&lt;/p&gt;

&lt;p&gt;It is also old. On 22 June 2000, Eric Du asked the PostgreSQL mailing list &lt;a href="https://www.postgresql.org/message-id/3951EDD6.FB29F9DE%40leyou.com" rel="noopener noreferrer"&gt;why he could not create two tables that were foreign keys for each other&lt;/a&gt; - his &lt;code&gt;INITIALLY DEFERRED&lt;/code&gt; attempt failed at &lt;code&gt;CREATE TABLE&lt;/code&gt; with &lt;code&gt;ERROR: Relation 't2' does not exist&lt;/code&gt;, because deferral postpones checking data, not the existence of a table that has not been created yet. Twenty-six years later the modelling version of the same knot is still being written up: a clear description of it puts the problem in one sentence, that &lt;a href="https://blog.sql-workbench.eu/post/cyclic-foreign-keys/" rel="noopener noreferrer"&gt;running two independent inserts will not work&lt;/a&gt; because you cannot insert into &lt;code&gt;department&lt;/code&gt; without a &lt;code&gt;manager_id&lt;/code&gt; and cannot insert into &lt;code&gt;employee&lt;/code&gt; without a &lt;code&gt;department_id&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can two tables have foreign keys to each other?
&lt;/h2&gt;

&lt;p&gt;Yes, and this is worth separating from the insert problem, because the two get conflated constantly.&lt;/p&gt;

&lt;p&gt;Creating the pair is a DDL ordering question. The first &lt;code&gt;CREATE TABLE&lt;/code&gt; cannot reference a table that does not exist, so the second constraint is added afterwards with &lt;code&gt;ALTER TABLE ... ADD CONSTRAINT&lt;/code&gt;. That is a mechanical detail, it works on every engine, and once both constraints exist the catalog is perfectly happy. Nothing about the &lt;em&gt;shape&lt;/em&gt; is rejected.&lt;/p&gt;

&lt;p&gt;Inserting into the pair is a different question with a different answer, and the answer depends entirely on one thing: whether every foreign key column in the cycle is &lt;code&gt;NOT NULL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If one of them is nullable, there is no problem at all. Insert the department with &lt;code&gt;manager_id&lt;/code&gt; NULL, insert the employee pointing at it, update the department. Two statements and an update, done once, at seed time.&lt;/p&gt;

&lt;p&gt;If all of them are &lt;code&gt;NOT NULL&lt;/code&gt;, there is no ordering that works, because ordering is not the difficulty. The set of rows the schema demands is self-referential, and no sequence of statements produces a set that contains itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which databases let you defer the foreign key check?
&lt;/h2&gt;

&lt;p&gt;The escape hatch is deferral: tell the engine to check the constraint at &lt;code&gt;COMMIT&lt;/code&gt; rather than after each statement, insert both rows inside one transaction, and let the two halves validate each other at the end. Whether you have that hatch is decided by the engine, not by the model.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Deferrable foreign keys&lt;/th&gt;
&lt;th&gt;What a NOT NULL cycle means here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;DEFERRABLE INITIALLY DEFERRED&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Both inserts in one transaction, if you can supply the key values yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oracle&lt;/td&gt;
&lt;td&gt;Yes, for constraints other than NOT NULL&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;Yes, with &lt;code&gt;PRAGMA foreign_keys = ON&lt;/code&gt; and an explicit transaction&lt;/td&gt;
&lt;td&gt;Same, and outside an explicit transaction deferred constraints behave as immediate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL and MariaDB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No transactional escape hatch at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL Server&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No transactional escape hatch at all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;MySQL's own manual is blunt about it, and states in the foreign key constraints page that because MySQL does not support deferred constraint checking, &lt;code&gt;NO ACTION&lt;/code&gt; is treated as &lt;code&gt;RESTRICT&lt;/code&gt;. SQL Server has no deferrable constraints either. On those engines a cycle of &lt;code&gt;NOT NULL&lt;/code&gt; foreign keys is not a puzzle to solve in the transaction, it is a schema you cannot use.&lt;/p&gt;

&lt;p&gt;And deferral is narrower than it first appears even where it exists. The PostgreSQL &lt;code&gt;CREATE TABLE&lt;/code&gt; documentation is explicit that only &lt;code&gt;UNIQUE&lt;/code&gt;, &lt;code&gt;PRIMARY KEY&lt;/code&gt;, &lt;code&gt;EXCLUDE&lt;/code&gt; and &lt;code&gt;REFERENCES&lt;/code&gt; constraints accept the clause, and that &lt;code&gt;NOT NULL&lt;/code&gt; and &lt;code&gt;CHECK&lt;/code&gt; constraints are not deferrable. So the &lt;code&gt;NOT NULL&lt;/code&gt; on &lt;code&gt;department.manager_id&lt;/code&gt; is still checked immediately. You are not allowed to insert the department with no manager and fix it at commit - you have to insert it pointing at an employee id that does not exist yet, which means generating the key yourself from a sequence or as a client-side UUID before either row is written. Deferral does not remove the chicken and egg. It relocates it into your insert code, where it becomes a requirement that the application knows both keys in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three shapes a foreign key cycle takes
&lt;/h2&gt;

&lt;p&gt;Only one of these is visible by eye, which is the whole difficulty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One table.&lt;/strong&gt; A self-reference: &lt;code&gt;categories.parent_id NOT NULL&lt;/code&gt; referencing &lt;code&gt;categories.id&lt;/code&gt;. The root category has no parent and cannot be written. This one is obvious in hindsight and still ships regularly, usually because &lt;code&gt;parent_id&lt;/code&gt; was made &lt;code&gt;NOT NULL&lt;/code&gt; for the honest reason that most rows do have a parent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two tables.&lt;/strong&gt; The mutual pair - &lt;code&gt;departments&lt;/code&gt; and &lt;code&gt;employees&lt;/code&gt;, &lt;code&gt;organizations&lt;/code&gt; and &lt;code&gt;owners&lt;/code&gt;, &lt;code&gt;carts&lt;/code&gt; and &lt;code&gt;checkouts&lt;/code&gt;. Visible on a diagram if the two entities happen to sit next to each other, invisible if they are eighty tables apart on a large canvas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three or more.&lt;/strong&gt; A ring: &lt;code&gt;A&lt;/code&gt; references &lt;code&gt;B&lt;/code&gt;, &lt;code&gt;B&lt;/code&gt; references &lt;code&gt;C&lt;/code&gt;, &lt;code&gt;C&lt;/code&gt; references &lt;code&gt;A&lt;/code&gt;. Nobody drew this and nobody can see it. Each of the three relationships is individually reasonable, each was added in a different quarter, and the cycle exists only in the graph they form together. No amount of staring at the ERD finds it, because there is no place on the diagram where the defect is located - it is a property of the whole model, in exactly the sense that a foreign key whose type does not match the key it references is a fact about two tables at once rather than about either one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you find a cycle you cannot see?
&lt;/h2&gt;

&lt;p&gt;You compute it. &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;Schema lint&lt;/a&gt; in Schemity ships seventeen rules, and &lt;code&gt;fk-cycle-all-not-null&lt;/code&gt; is one of the three in the group that fails at runtime: a cycle of foreign keys in which every column is &lt;code&gt;NOT NULL&lt;/code&gt;, with a table referencing itself as the simplest case. It walks the relationships in the open diagram, so the three-table ring is found on exactly the same terms as the self-reference - the number of hops makes no difference to a graph traversal and all the difference to a person.&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%2Fnba0041mhm1qe0zkf17g.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%2Fnba0041mhm1qe0zkf17g.webp" alt="Schemity's Lint panel with a Fails at runtime group holding two findings, table1 &gt; table3 &gt; table2 and table5 &gt; table6, each reporting that every foreign key in the cycle is NOT NULL so no row can be inserted, beside a canvas where table1, table2 and table3 form a ring of three ordinary-looking relationships" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both invisible shapes are sitting in that one panel. &lt;code&gt;table5 &amp;gt; table6&lt;/code&gt; is the mutual pair and &lt;code&gt;table1 &amp;gt; table3 &amp;gt; table2&lt;/code&gt; is the ring, and the rule reports them identically because to a traversal they are one defect at two lengths. Look at the canvas underneath and the ring is three unremarkable relationships, each drawn between a different pair of tables, none of which says cycle by itself. The finding names the hops in order - &lt;code&gt;table1 → table3 → table2 → table1&lt;/code&gt; - says what it costs, that each &lt;code&gt;INSERT&lt;/code&gt; needs a row that does not exist yet, and states both fixes in the same breath: make one of these foreign keys nullable, or declare them &lt;code&gt;DEFERRABLE&lt;/code&gt; on PostgreSQL and insert the rows in one transaction.&lt;/p&gt;

&lt;p&gt;Two properties of how that finding is reported matter more than the check itself.&lt;/p&gt;

&lt;p&gt;It runs against the model, not the server. Every input the rule needs - which relationships exist, which columns are nullable - is already written down in the diagram, so the check needs the schema rather than a connection, in exactly the way &lt;a href="https://schemity.com/blog/postgres-timestamp-vs-timestamptz/" rel="noopener noreferrer"&gt;a rule reading which date columns are missing their time zone&lt;/a&gt; needs nothing but the types in front of it. You get the answer while designing, on a plane, before the migration exists, rather than at seed time on an environment that does not exist yet.&lt;/p&gt;

&lt;p&gt;And it lands on the diagram. Every finding carries a &lt;strong&gt;Show on canvas&lt;/strong&gt; link, and taking it draws a colored strip in the margin beside each entity in the cycle, at the exact field row concerned, rather than leaving you with a list to translate back into the picture - the orange strip visible on the entity at the top of that canvas is the same mechanism reporting the unrelated &lt;code&gt;change_histories.action_type&lt;/code&gt; finding. Nullability is already legible there: a nullable field carries a green &lt;strong&gt;N&lt;/strong&gt; badge on the entity, so once you break the cycle, the thing that broke it is visible on the canvas at a glance instead of being a fact you have to remember. &lt;a href="https://schemity.com/doc/relationships/" rel="noopener noreferrer"&gt;Clicking the relationship&lt;/a&gt; highlights both ends, the foreign key field on one entity and the primary key it points at on the other, which is how you trace a ring back through its hops.&lt;/p&gt;

&lt;p&gt;If the cycle is deliberate and you have solved it with deferral, ignore that single finding. The ignore is saved in the diagram's JSON file and travels with it, so the decision is recorded once for the team rather than re-dismissed by each person who opens the file.&lt;/p&gt;

&lt;p&gt;One honest limit: Schemity has no &lt;code&gt;DEFERRABLE&lt;/code&gt; toggle on a relationship. It models cardinality, &lt;code&gt;ON DELETE&lt;/code&gt; and &lt;code&gt;ON UPDATE&lt;/code&gt;, and its answer to a cycle is the modelling decision rather than a constraint flag - so a deferrable constraint is something you add in the migration and record in the ignore, not something the ERD holds for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which side should be nullable?
&lt;/h2&gt;

&lt;p&gt;Assuming you are not deferring, one column in the cycle has to accept NULL, and the choice is not arbitrary.&lt;/p&gt;

&lt;p&gt;Pick the side where absence is a real state of the world, not the side that is easier to change. A department genuinely can exist before its manager is appointed, so &lt;code&gt;departments.manager_id&lt;/code&gt; being nullable describes reality. An employee who belongs to no department is usually a data error, so making &lt;code&gt;employees.department_id&lt;/code&gt; nullable to fix an insert ordering problem quietly legalises a row nobody wants. Both choices unblock the insert. Only one of them is still true a year later.&lt;/p&gt;

&lt;p&gt;The reason this matters beyond taste is that NULL then means something, and every query has to handle it. A nullable foreign key is also a nullable column in every unique constraint it participates in, &lt;a href="https://schemity.com/blog/unique-constraints-and-nullable-columns/" rel="noopener noreferrer"&gt;where NULLs compare as distinct and the constraint stops enforcing what it appears to enforce&lt;/a&gt;. Choosing the wrong side to relax buys one insert and pays for it in every read.&lt;/p&gt;

&lt;p&gt;The third option is to remove the cycle rather than survive it. If both directions are genuinely required and neither absence is real, the reference that does not belong to the entity moves into its own table: a &lt;code&gt;department_managers&lt;/code&gt; table holding &lt;code&gt;(department_id, employee_id)&lt;/code&gt; with a unique constraint on &lt;code&gt;department_id&lt;/code&gt; says one manager per department, &lt;a href="https://schemity.com/blog/many-to-many-shouldnt-mean-hand-building-the-junction-table/" rel="noopener noreferrer"&gt;in the same shape a junction table uses for the many-to-many case&lt;/a&gt;, and both original tables now point one way only. The cost is a join. The benefit is a schema with no cycle in it at all, which is also a schema whose rows can be inserted in any order, on any engine, forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a cycle does to deletes
&lt;/h2&gt;

&lt;p&gt;The insert is the loud failure. The delete is the quiet one.&lt;/p&gt;

&lt;p&gt;Referential actions are evaluated in the same graph, so a cycle carrying &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; describes a delete that travels back to where it started. Engines guard against the infinite case, but the guard varies - SQL Server refuses to create cyclic cascade paths outright, MySQL and PostgreSQL accept them and resolve the traversal at runtime - and the practical result is that the blast radius of one &lt;code&gt;DELETE&lt;/code&gt; in a cycle is genuinely hard to reason about from the SQL. Schemity draws a bold crow's foot at the child end of any relationship whose foreign key cascades, so &lt;a href="https://schemity.com/blog/on-delete-cascade-is-invisible-in-your-erd/" rel="noopener noreferrer"&gt;the rows a parent delete takes with it are visible on the canvas&lt;/a&gt; without opening a dialog, and a cascade inside a cycle reads as a bold ring rather than as three unrelated decisions.&lt;/p&gt;

&lt;p&gt;At the architecture scale the same question repeats between groups of tables rather than between tables. A context view is a focused subset of the main diagram - one domain's entities, arranged for reading, with the main view still holding the schema. On the &lt;a href="https://schemity.com/doc/context-map/" rel="noopener noreferrer"&gt;Context Map&lt;/a&gt;, each context view becomes a node and arrows carry the count of foreign keys flowing in each direction, with a curved arrow rather than a straight one where two contexts depend on each other - so a mutual dependency between two bounded contexts is a shape you scan for rather than a thing you audit. Indirect cycles across three or more contexts are the same invisible case one level up, which is why the AI chat reads the map to answer that question directly instead of asking you to trace arrows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;p&gt;A foreign key cycle where every column is &lt;code&gt;NOT NULL&lt;/code&gt; is a schema that compiles and cannot run. Deferral helps on PostgreSQL, Oracle and SQLite and does not exist on MySQL or SQL Server, and even where it exists it hands the ordering problem to your insert code rather than removing it. The durable fixes are both modelling decisions: make the side where absence is real nullable, or move the reference into its own table.&lt;/p&gt;

&lt;p&gt;What you should not rely on is seeing it. Two of the three shapes are invisible on a canvas, which is the general property of a schema defect that involves more than one object at a time - the same reason a link table with nothing enforcing uniqueness over its pair looks exactly like one that is correct, and why the ERD is the right place to check the model rather than &lt;a href="https://schemity.com/blog/schema-linting-vs-migration-linting/" rel="noopener noreferrer"&gt;the migration that only ever shows you the delta&lt;/a&gt;. Draw the relationships, then let something walk them.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>postgres</category>
      <category>mysql</category>
    </item>
    <item>
      <title>PostgreSQL timestamp vs timestamptz: Which to Use and How to Find the Wrong Ones</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Wed, 19 Aug 2026 01:47:12 +0000</pubDate>
      <link>https://dev.to/tbson87/postgresql-timestamp-vs-timestamptz-which-to-use-and-how-to-find-the-wrong-ones-57cf</link>
      <guid>https://dev.to/tbson87/postgresql-timestamp-vs-timestamptz-which-to-use-and-how-to-find-the-wrong-ones-57cf</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; Use timestamptz for anything that records when something happened, and timestamp only for a wall-clock time meant to read the same in every zone. Both types occupy 8 bytes, so the correct one is free, but Rails and Prisma both default to the naive one, which is how a schema ends up with hundreds of columns nobody chose. A migration linter cannot see them because the statements that created them were merged years ago; Schemity's schema lint reads the whole open ERD instead and marks each field row in the margin, with the conversion routed through a migration SQL diff you review first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Use &lt;code&gt;timestamptz&lt;/code&gt; for any column that records when something happened, and &lt;code&gt;timestamp&lt;/code&gt; only for a wall-clock reading that is meant to mean the same thing in every zone. Both types take 8 bytes, so the correct one is free. The reason most schemas have the wrong one anyway is that nobody chose it: Rails' &lt;code&gt;t.datetime&lt;/code&gt; and Prisma's &lt;code&gt;DateTime&lt;/code&gt; both emit the naive type unless you go out of your way, and by the time it matters the migration that created the column was merged years ago.&lt;/p&gt;

&lt;p&gt;That is what makes this different from a normal type mistake. A &lt;code&gt;varchar(20)&lt;/code&gt; that should have been &lt;code&gt;text&lt;/code&gt; announces itself the first time a value does not fit. A &lt;code&gt;timestamp&lt;/code&gt; column behaves perfectly for years, then produces an hour of wrong answers at a daylight saving boundary, or shifts an entire reporting dashboard the day someone changes the server's time zone. Nothing in the schema looks wrong at any point, because nothing in the schema is invalid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I use timestamp or timestamptz in PostgreSQL?
&lt;/h2&gt;

&lt;p&gt;PostgreSQL's own wiki answers this in a page called Don't Do This, where &lt;a href="https://wiki.postgresql.org/wiki/Don%27t_Do_This" rel="noopener noreferrer"&gt;don't use timestamp (without time zone)&lt;/a&gt; sits alongside its advice against &lt;code&gt;char(n)&lt;/code&gt;, &lt;code&gt;money&lt;/code&gt; and &lt;code&gt;serial&lt;/code&gt;. The description there is the clearest one available: &lt;code&gt;timestamp&lt;/code&gt; stores "a date and time you give it", which the wiki compares to a picture of a calendar and a clock, while &lt;code&gt;timestamptz&lt;/code&gt; "records a single moment in time" and does the right thing with arithmetic between values entered in different zones.&lt;/p&gt;

&lt;p&gt;The practical difference:&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;timestamp&lt;/code&gt; (without time zone)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;timestamptz&lt;/code&gt; (with time zone)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What the value means&lt;/td&gt;
&lt;td&gt;The reading on a clock, with no clock stated&lt;/td&gt;
&lt;td&gt;An instant, the same one for every reader&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On write&lt;/td&gt;
&lt;td&gt;Stored exactly as given&lt;/td&gt;
&lt;td&gt;Converted to UTC using the session zone or the stated offset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On read&lt;/td&gt;
&lt;td&gt;Returned exactly as stored&lt;/td&gt;
&lt;td&gt;Converted back to the reader's session zone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arithmetic across a DST boundary&lt;/td&gt;
&lt;td&gt;Wrong by an hour, silently&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two servers in different zones&lt;/td&gt;
&lt;td&gt;Disagree about what the row says&lt;/td&gt;
&lt;td&gt;Agree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;8 bytes&lt;/td&gt;
&lt;td&gt;8 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Right for&lt;/td&gt;
&lt;td&gt;A time that is local by definition&lt;/td&gt;
&lt;td&gt;Everything that happened&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the whole decision. &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;deleted_at&lt;/code&gt;, &lt;code&gt;published_at&lt;/code&gt;, &lt;code&gt;last_seen_at&lt;/code&gt;, every audit column and every event time are instants, and they belong in &lt;code&gt;timestamptz&lt;/code&gt;. A shop that opens at 09:00 in whatever zone it stands in, a birthday, a recurring alarm the user wants at 07:00 no matter which country they wake up in - those are wall-clock readings, and &lt;code&gt;timestamp&lt;/code&gt; is genuinely the right type for them. The set of columns in the second category is much smaller than the number of &lt;code&gt;timestamp&lt;/code&gt; columns in a typical database, which is the tell.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does timestamptz actually store?
&lt;/h2&gt;

&lt;p&gt;Not a time zone. This is worth stating plainly because the objection to using the type is usually built on the assumption that it does, and that the extra information costs something.&lt;/p&gt;

&lt;p&gt;The PostgreSQL &lt;a href="https://www.postgresql.org/docs/current/datatype-datetime.html" rel="noopener noreferrer"&gt;date/time types documentation&lt;/a&gt; is explicit on both halves. On what is kept: an input string with an explicit zone "will be converted to UTC using the appropriate offset for that time zone", and "in either case, the value is stored internally as UTC, and the originally stated or assumed time zone is not retained." On what it costs: the types table gives &lt;code&gt;timestamp [ (p) ] [ without time zone ]&lt;/code&gt; and &lt;code&gt;timestamp [ (p) ] with time zone&lt;/code&gt; a storage size of 8 bytes each, both spanning 4713 BC to 294276 AD at 1 microsecond resolution.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;timestamptz&lt;/code&gt; is not a richer type that remembers where a row came from. It is the same 8 bytes with a defined meaning attached, and a conversion applied at the edges. If you need to know that a booking was entered in Europe/Berlin - because a future rule about that booking depends on the zone rather than the instant - that is a second column holding the zone name, and it is a modelling decision rather than a type choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did my ORM create timestamp without time zone?
&lt;/h2&gt;

&lt;p&gt;Because that is what it does by default, in the two frameworks most likely to have generated your schema.&lt;/p&gt;

&lt;p&gt;Rails translates &lt;code&gt;t.datetime&lt;/code&gt; to &lt;code&gt;timestamp without time zone&lt;/code&gt; on PostgreSQL. The &lt;a href="https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/PostgreSQLAdapter.html" rel="noopener noreferrer"&gt;&lt;code&gt;datetime_type&lt;/code&gt; setting&lt;/a&gt; that lets you change it to &lt;code&gt;:timestamptz&lt;/code&gt; arrived in Rails 7.0, more than six years after the behaviour was reported: the issue &lt;a href="https://github.com/rails/rails/issues/21126" rel="noopener noreferrer"&gt;standard migrations will generate a "timestamp without timezone" field&lt;/a&gt; was opened on 4 August 2015, with the complaint that &lt;code&gt;AT TIME ZONE&lt;/code&gt; arithmetic then quietly uses the database server's zone rather than the one intended. The default itself has not changed, so a Rails application generated today still produces naive columns unless somebody adds the initializer.&lt;/p&gt;

&lt;p&gt;Prisma does the same by a different route. Its schema reference maps the &lt;code&gt;DateTime&lt;/code&gt; scalar to &lt;code&gt;timestamp(3)&lt;/code&gt; on PostgreSQL, with &lt;code&gt;@db.Timestamptz(x)&lt;/code&gt; available as a native type attribute you apply per field. The documented fix is to write &lt;code&gt;@db.Timestamptz(6)&lt;/code&gt; on every date column you care about, and the recurring &lt;a href="https://github.com/prisma/prisma/issues/7825" rel="noopener noreferrer"&gt;reports of time zone surprises&lt;/a&gt; are largely the gap between that annotation existing and anyone knowing to type it forty times.&lt;/p&gt;

&lt;p&gt;This is the same shape as &lt;a href="https://schemity.com/blog/postgres-enum-vs-check-constraint-vs-lookup-table/" rel="noopener noreferrer"&gt;the ORM quietly deciding how a status column is constrained&lt;/a&gt;: the framework picks a physical representation, the choice never surfaces in a code review because there is nothing to review, and the schema ends up expressing a decision nobody made.&lt;/p&gt;

&lt;p&gt;MySQL inverts the vocabulary, which is worth knowing if you move between engines. Its manual states that MySQL "converts &lt;code&gt;TIMESTAMP&lt;/code&gt; values from the current time zone to UTC for storage, and back from UTC to the current time zone for retrieval", and that this "does not occur for other types such as &lt;code&gt;DATETIME&lt;/code&gt;". So MySQL's &lt;code&gt;TIMESTAMP&lt;/code&gt; behaves like PostgreSQL's &lt;code&gt;timestamptz&lt;/code&gt;, MySQL's &lt;code&gt;DATETIME&lt;/code&gt; is the naive one, and the zone-aware type there is the one carrying a range limit: &lt;code&gt;1970-01-01 00:00:01&lt;/code&gt; to &lt;code&gt;2038-01-19 03:14:07&lt;/code&gt; UTC, against &lt;code&gt;DATETIME&lt;/code&gt;'s year 9999.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you find the timestamp columns you already have?
&lt;/h2&gt;

&lt;p&gt;The advice to use &lt;code&gt;timestamptz&lt;/code&gt; is easy to accept and useless on its own, because the columns that need fixing were created before you accepted it. The real question is which of the two hundred date columns in the schema are wrong, and that is a question about the schema as it stands rather than about anything you are writing today.&lt;/p&gt;

&lt;p&gt;A migration linter cannot answer it. Squawk carries a &lt;code&gt;prefer-timestamptz&lt;/code&gt; rule, and it is a good rule, but it fires on the column being added in the file being checked - which is why &lt;a href="https://schemity.com/blog/schema-linting-vs-migration-linting/" rel="noopener noreferrer"&gt;checking migrations and checking the schema are two different jobs&lt;/a&gt;. A schema that already holds three hundred naive columns produces no findings at all, forever, because none of those statements are in front of it.&lt;/p&gt;

&lt;p&gt;Schemity checks the model instead. &lt;code&gt;timestamp-not-timestamptz&lt;/code&gt; is one of the seventeen rules in &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;schema lint&lt;/a&gt;, and it reports the same value reading as a different instant depending on the session time zone. It runs against the diagram you have open, on your machine, with no connection required - so &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;a schema reverse engineered from the live database&lt;/a&gt; is checked in its entirety, every column at once, including the ones created in 2019.&lt;/p&gt;

&lt;p&gt;The finding lands in the margin beside the exact field row rather than in a list you have to translate back into the picture, which matters here more than for most rules: a table with &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;updated_at&lt;/code&gt; and &lt;code&gt;published_at&lt;/code&gt; where only one was fixed during some earlier cleanup shows you precisely which one is still naive. The type name is already on the canvas next to it, since entities render each field's type and default directly, so the before and after are both readable without opening anything.&lt;/p&gt;

&lt;p&gt;Two honest limits. The rule is PostgreSQL-only - it and &lt;code&gt;fk-array-column&lt;/code&gt; are the two of the seventeen that are, and the others run on every engine Schemity connects to - so a MySQL diagram is not asked this question at all, and the &lt;code&gt;DATETIME&lt;/code&gt; version of it is not a rule today. And the field type icons deliberately will not help you: types are grouped by what the value is rather than by what the engine calls it, so &lt;code&gt;DATE&lt;/code&gt; and &lt;code&gt;TIMESTAMPTZ&lt;/code&gt; share one calendar glyph. The icon tells you it is a time, the type text and the lint strip tell you which one.&lt;/p&gt;

&lt;p&gt;The rule sits in the group called Costs, next to &lt;code&gt;money-as-float&lt;/code&gt;, and the grouping is the point. It is not a runtime failure like &lt;a href="https://schemity.com/blog/circular-foreign-keys-first-row-cannot-be-inserted/" rel="noopener noreferrer"&gt;a foreign key cycle that no row can be inserted into&lt;/a&gt;. It works. It charges you an hour of wrong answers twice a year and a permanent inability to compare two rows written on different servers, which is the sort of bill that never arrives all at once.&lt;/p&gt;

&lt;p&gt;When you do fix one, the conversion goes through the normal path: change the type in the ERD, read the generated &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL diff&lt;/a&gt;, then apply it. That review is not ceremony for this particular change, because converting a naive column means stating what its values meant - &lt;code&gt;USING created_at AT TIME ZONE 'UTC'&lt;/code&gt; shifts every row by a fixed offset if the assumption is wrong, and the diff is where you see the clause before it runs rather than after.&lt;/p&gt;

&lt;p&gt;If the column is deliberately naive, ignore the finding once. The ignore is stored in the diagram's JSON file and travels with it, so a store-opening-hours column reads as a decision the team made rather than a note each person re-dismisses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the next hundred columns right for free
&lt;/h2&gt;

&lt;p&gt;The columns that do not exist yet are the cheap half of this problem, and the fix is not vigilance.&lt;/p&gt;

&lt;p&gt;Every new entity in Schemity starts from the active &lt;a href="https://schemity.com/doc/templates/" rel="noopener noreferrer"&gt;template&lt;/a&gt;, so a baseline of &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;created_at TIMESTAMPTZ&lt;/code&gt; and &lt;code&gt;updated_at TIMESTAMPTZ&lt;/code&gt; means the convention is what happens when you do nothing, rather than something enforced afterwards by a linter or a reviewer. That is the same reasoning behind fixing the ORM default: an initializer setting &lt;code&gt;datetime_type = :timestamptz&lt;/code&gt;, or a Prisma annotation on the field, moves the decision from every future migration to one line, once.&lt;/p&gt;

&lt;p&gt;The remaining work is the audit of what is already there, and it is a finite list rather than an ongoing discipline. Open the diagram, read the margin, convert the columns that record instants, ignore the handful that genuinely record clock readings, and the question is closed for the life of the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;timestamp&lt;/code&gt; stores what a clock said, &lt;code&gt;timestamptz&lt;/code&gt; stores when something happened, both cost 8 bytes, and the second one is what almost every column in your schema means. Your ORM picked the first, in Rails since forever and in Prisma by default, which is why the columns to fix outnumber the ones you would ever write by hand. A migration linter cannot find them because their statements are years behind you. Read them off the model instead: the schema is the thing that is wrong, so the schema is the thing to check.&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>sql</category>
      <category>orm</category>
    </item>
    <item>
      <title>Export a Database Data Dictionary: HTML, Markdown and Excel from Your ERD</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Mon, 17 Aug 2026 01:55:03 +0000</pubDate>
      <link>https://dev.to/tbson87/export-a-database-data-dictionary-html-markdown-and-excel-from-your-erd-4oo7</link>
      <guid>https://dev.to/tbson87/export-a-database-data-dictionary-html-markdown-and-excel-from-your-erd-4oo7</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 people who most need to read your schema - auditors, analysts, a new hire on day one - will never install your ERD tool, so the documentation gets retyped into a spreadsheet or replaced by a picture that answers nothing. Schemity exports the diagram as a data dictionary in HTML, Markdown and Excel, with every column's type, default, nullability and description plus the constraints and relationships, and ends by counting what is still undocumented.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A data dictionary is a document, not a diagram, and it should be generated from the model you already keep rather than retyped into a spreadsheet. Schemity exports the current diagram as a data dictionary in three formats, covering every entity and column with its type, key role, nullability, default and description, the unique, check and index constraints on each table, and the relationships with their cardinality and delete rules.&lt;/p&gt;

&lt;p&gt;The reason that matters has nothing to do with file formats. It is about who is asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I generate a data dictionary from my database?
&lt;/h2&gt;

&lt;p&gt;From the diagram, if the diagram is already an accurate model of the database. That is the whole trick, and it is why the answer is usually so unsatisfying: most teams do not have a diagram that is current, so they fall back to querying the catalog by hand and pasting the results into a spreadsheet.&lt;/p&gt;

&lt;p&gt;The people who need the document are rarely the people who own the tooling. An auditor wants to know what personal data the schema holds and where. A business analyst wants to filter a column list and sort it. A client at the end of an engagement wants something they can keep. A new engineer on day one wants to read the schema before touching it. Not one of them is going to install a desktop ERD tool, connect to a database and learn a canvas to get an answer, and none of their questions are answered by a picture of boxes and lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a diagram is not a data dictionary
&lt;/h2&gt;

&lt;p&gt;A diagram shows shape. It shows that &lt;code&gt;invoices&lt;/code&gt; points at &lt;code&gt;customers&lt;/code&gt; and that &lt;code&gt;line_items&lt;/code&gt; hangs off &lt;code&gt;invoices&lt;/code&gt;. It does not tell you that &lt;code&gt;invoices.voided_at&lt;/code&gt; is a nullable timestamp with no default, that &lt;code&gt;status&lt;/code&gt; is constrained to four values, or that the delete rule on that relationship is &lt;code&gt;RESTRICT&lt;/code&gt; rather than &lt;code&gt;CASCADE&lt;/code&gt;. Those facts are the document's entire job, and exporting the canvas as an image gives you none of them - which is a different failure from &lt;a href="https://schemity.com/blog/why-your-erd-export-turns-blurry/" rel="noopener noreferrer"&gt;the image going blurry when someone zooms in&lt;/a&gt;, and a worse one.&lt;/p&gt;

&lt;p&gt;So the document gets built by hand, and the size of the job is what kills it. A practitioner writing about &lt;a href="https://www.sqlservercentral.com/articles/re-oiling-the-gears-for-the-data-dictionary-or-catalog" rel="noopener noreferrer"&gt;re-oiling the gears for the data dictionary&lt;/a&gt; describes a warehouse holding "tens of thousands of columns across the various tables and views" and puts the failure plainly: "the size of the effort is always a barrier to doing it." He also notes that "opposition to providing documentation is always quite vocal", which is what happens when the ask is a manual one.&lt;/p&gt;

&lt;p&gt;The free generators are the obvious escape, and they are genuinely useful when they work. They are also a project of their own: a Java runtime, the right JDBC driver, historically a Graphviz install, and a command line to get right before you see a single page. When it goes wrong it goes wrong quietly - one user pointed the standard generator at AdventureWorks2016 and &lt;a href="https://github.com/schemaspy/schemaspy/issues/499" rel="noopener noreferrer"&gt;got three tables back&lt;/a&gt;, reporting in January 2019 that the output was "Just some tables and columns. MIssing tables, relationships, constraints everting :(". A documentation pipeline that can silently emit a partial document is worse than no pipeline, because the partial document looks finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three formats, three readers
&lt;/h2&gt;

&lt;p&gt;The dictionary exports as HTML, Markdown or an Excel workbook, and the reason there are three is that the three readers want genuinely different things.&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%2Fp4ytcl8q6irila35hnur.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%2Fp4ytcl8q6irila35hnur.webp" alt="Schemity's Export panel listing nine formats: PNG, JPEG and SVG images, SQL, DBML and Mermaid files, then Data dictionary as HTML, Markdown and Excel" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The export panel says the distinction out loud. The first three entries are images, the next three are code - &lt;code&gt;.sql&lt;/code&gt; CREATE statements, &lt;code&gt;.dbml&lt;/code&gt; table and ref definitions, &lt;code&gt;.mmd&lt;/code&gt; erDiagram source - and the last three are documents: &lt;code&gt;.html&lt;/code&gt; described as entities, fields, constraints and descriptions ready to print or share, &lt;code&gt;.md&lt;/code&gt; as the same tables for committing next to the code, and &lt;code&gt;.xlsx&lt;/code&gt; as a workbook of overview, entities, fields, constraints, relationships and notes. A picture, a definition of the schema, and a description of it are three different artifacts, and only the third one answers a question about a column.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Reader&lt;/th&gt;
&lt;th&gt;What it is for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTML&lt;/td&gt;
&lt;td&gt;Someone who will never open Schemity&lt;/td&gt;
&lt;td&gt;Print it, attach it, hand it over at the end of an engagement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Markdown&lt;/td&gt;
&lt;td&gt;The engineers&lt;/td&gt;
&lt;td&gt;Commit it beside the code so the document is reviewed in the same pull request as the migration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Excel&lt;/td&gt;
&lt;td&gt;Analysts and auditors&lt;/td&gt;
&lt;td&gt;Filter, sort and pivot a column list; six sheets covering overview, entities, fields, constraints, relationships and notes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Excel workbook always carries all six sheets, even when a sheet has no rows, so the shape of the file does not change with the diagram and a spreadsheet someone built on top of last quarter's export still works on this quarter's. Column counts are written as numbers rather than text, so they total - a small thing that decides whether the workbook is usable or merely present.&lt;/p&gt;

&lt;p&gt;The export follows the active view. Exporting from &lt;a href="https://schemity.com/doc/context-views/" rel="noopener noreferrer"&gt;a context view&lt;/a&gt; documents that context alone rather than the whole database. A context view is a saved, focused view of your schema that shows only a subset of entities and the relationships between them, so the billing context can be documented and handed to the payments team without shipping them seventy unrelated tables. The &lt;a href="https://schemity.com/doc/legends-and-annotations/" rel="noopener noreferrer"&gt;descriptions written on entities and legends&lt;/a&gt; travel into the document too, which is what turns a column list into something worth reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why database views belong in the data dictionary
&lt;/h2&gt;

&lt;p&gt;Schemity's SQL, DBML and Mermaid exports leave database views out, and that is correct: those formats describe tables you can create, and a view is not one of them. A document is a different job. Someone checking what exists in the database needs to see the reporting layer and the security boundary, and an export that silently omits half the schema answers nothing.&lt;/p&gt;

&lt;p&gt;So views appear in the dictionary, labelled as views rather than dropped. If your reporting layer or your PostgREST API surface is built on views, they are frequently the objects an analyst cares about most, and &lt;a href="https://schemity.com/blog/database-views-in-your-erd/" rel="noopener noreferrer"&gt;leaving views out of the model entirely&lt;/a&gt; is how they end up as undocumented infrastructure that nobody dares change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dictionary tells you what is not documented yet
&lt;/h2&gt;

&lt;p&gt;Every engine will hold a description for every object. PostgreSQL stores them in &lt;code&gt;pg_description&lt;/code&gt; and hands them back through &lt;code&gt;col_description&lt;/code&gt;; the feature has been there for decades. What no engine offers is the second question: how many objects actually have one.&lt;/p&gt;

&lt;p&gt;That gap is why documentation drives get abandoned. The same practitioner above wrote test procedures to "highlight any tables or columns with blank descriptions" and found they produced "too many failed tests when plugged into a legacy system" to be usable. The mechanism was right and the framing was wrong - graded as failures, an inherited schema is nothing but failures, and the report gets muted on the first run.&lt;/p&gt;

&lt;p&gt;Schemity's data dictionary closes with the same information stated as facts rather than verdicts: how many entities and fields carry a description, and the names of those that do not. It counts only what can actually be changed, so the columns of a database view, which are read-only, are never listed as missing anything. It states the numbers and stops there. On a legacy database the first export will say something bleak, and that is the point - the number is a work list, and it is the only measurement of documentation coverage most teams have ever had.&lt;/p&gt;

&lt;p&gt;This is the same principle that makes &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;schema lint report facts rather than grades&lt;/a&gt;: a deliberate choice reported as a mistake teaches people to ignore the report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generate it, do not maintain it
&lt;/h2&gt;

&lt;p&gt;The reason a spreadsheet dictionary dies is that it is a second artifact requiring manual synchronization, and that arrangement fails eventually every time. The fix is not a better spreadsheet. It is to make the document a build output of a model that is already kept honest - one set of entities that &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;the live database can be re-read into&lt;/a&gt; when it moves, with the document regenerated from it in one action.&lt;/p&gt;

&lt;p&gt;That is the practical difference between documentation you write and documentation you produce. It is also why the &lt;a href="https://schemity.com/blog/the-data-dictionary-should-live-in-the-erd/" rel="noopener noreferrer"&gt;meaning of the schema has to live in the diagram in the first place&lt;/a&gt; rather than in a wiki: descriptions attached to the model get carried into every export, and descriptions attached to a document get carried nowhere. For an inherited database, &lt;a href="https://schemity.com/blog/you-dont-need-a-diagram-of-all-800-tables/" rel="noopener noreferrer"&gt;documenting it without diagramming all 800 tables&lt;/a&gt; and exporting one context at a time is the version of this that finishes.&lt;/p&gt;

</description>
      <category>database</category>
      <category>documentation</category>
      <category>sql</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Schema Linting vs Migration Linting: Which Database Problems Each One Can See</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Fri, 14 Aug 2026 07:31:37 +0000</pubDate>
      <link>https://dev.to/tbson87/schema-linting-vs-migration-linting-which-database-problems-each-one-can-see-1kp4</link>
      <guid>https://dev.to/tbson87/schema-linting-vs-migration-linting-which-database-problems-each-one-can-see-1kp4</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 migration linter parses one SQL statement and answers whether running it is safe, which is why it cannot see a foreign key whose type does not match the key it references, or a unique constraint that enforces nothing because a nullable column sits in it. Those are facts about the whole schema, so the only checks available today are a human reviewer with a checklist and production. Schemity lints the ERD instead: seventeen rules, run offline against the model, grouped by what each finding costs, and marked in the margin next to the exact entity and field row concerned.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A migration linter reads the statement you are about to run. A schema linter reads the model you already have. Both are useful, and only one of them can tell you that the &lt;code&gt;int&lt;/code&gt; foreign key in &lt;code&gt;orders&lt;/code&gt; points at a &lt;code&gt;bigint&lt;/code&gt; primary key in &lt;code&gt;customers&lt;/code&gt; - because that is not a fact about any single statement, it is a fact about two tables at once.&lt;/p&gt;

&lt;p&gt;This distinction is easy to miss, because the tooling that exists is almost entirely on one side of it. The mature, well-adopted tools in this space check migrations: they parse the SQL in your migration file and warn about locks, rewrites, and changes that break clients still running the old code. That is genuinely valuable, and it is a different question from the one that keeps costing teams years of cleanup work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is there a linter for a database schema, not just for migrations?
&lt;/h2&gt;

&lt;p&gt;Not in the same shape, and the reason is structural rather than an oversight. Look at what a migration linter is given: one file, containing statements, arriving one deploy at a time.&lt;/p&gt;

&lt;p&gt;Squawk, the most established of them, &lt;a href="https://squawkhq.com/docs/rules" rel="noopener noreferrer"&gt;ships 43 rules&lt;/a&gt; whose stated focus is "ensuring safe migrations and warn about statements that could block reads / writes or break existing clients." The rule names tell you the shape of the job: &lt;code&gt;require-concurrent-index-creation&lt;/code&gt;, &lt;code&gt;adding-field-with-default&lt;/code&gt;, &lt;code&gt;ban-drop-column&lt;/code&gt;. A few of them do reach into design - &lt;code&gt;prefer-bigint-over-int&lt;/code&gt; and &lt;code&gt;prefer-timestamptz&lt;/code&gt; are both there - but they fire on the column being added, in the statement being read. A schema that already has three hundred &lt;code&gt;timestamp&lt;/code&gt; columns produces no findings at all, because none of them are in the file.&lt;/p&gt;

&lt;p&gt;That is the boundary. A migration linter sees the delta. It cannot see the schema, because the schema was never handed to it.&lt;/p&gt;

&lt;p&gt;So what checks the schema? In practice, two things. A human, and production.&lt;/p&gt;

&lt;p&gt;The human version is more formal than most teams admit. GitLab's &lt;a href="https://docs.gitlab.com/development/database_review/" rel="noopener noreferrer"&gt;database review guidelines&lt;/a&gt; staff it as two named roles: a &lt;strong&gt;Database Reviewer&lt;/strong&gt; does the first pass and relabels the merge request, then a &lt;strong&gt;Database Maintainer&lt;/strong&gt; does the final review and approves it. The checklist those roles work through includes items like "Check indexes are present for foreign keys" and "Add foreign keys to any columns pointing to data in other tables, including an index." Those are eyeball checks, run per merge request, by two people, forever. It works - and it works because GitLab pays for it with a standing process most teams do not have.&lt;/p&gt;

&lt;p&gt;The production version is the one everybody has by default. On MySQL it announces itself as errno 150, &lt;code&gt;Cannot add foreign key constraint&lt;/code&gt;, at the moment you try to add a constraint between an &lt;code&gt;int&lt;/code&gt; column and a &lt;code&gt;bigint&lt;/code&gt; key. On PostgreSQL nothing fails - the constraint is created happily, and you find out later, when the join that should have used an index does not, or when the parent's &lt;code&gt;id&lt;/code&gt; sequence passes 2,147,483,647 and the child column cannot hold the values any more. GitLab's own issue tracker carries a long line of tickets titled &lt;em&gt;Prepare foreign key constraints for bigint&lt;/em&gt; - one per column, each a separate piece of work, because converting an &lt;code&gt;int&lt;/code&gt; foreign key on a large table is not an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a foreign key type mismatch survives code review
&lt;/h2&gt;

&lt;p&gt;Because the defect has two halves and the review has one.&lt;/p&gt;

&lt;p&gt;The migration under review says &lt;code&gt;ADD COLUMN customer_id int&lt;/code&gt;. There is nothing wrong with that line. It becomes wrong only in the presence of &lt;code&gt;customers.id bigint&lt;/code&gt;, which lives in a different migration file, written by a different person, possibly three years earlier. To catch it, the reviewer has to already know the other type, or stop and go find it - for every foreign key, in every migration, forever.&lt;/p&gt;

&lt;p&gt;The same structure repeats across the problems that actually cost money:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;unique constraint containing a nullable column&lt;/strong&gt; reads as correct in any schema dump. &lt;code&gt;UNIQUE (tenant_id, email, deleted_at)&lt;/code&gt; looks like a rule. It enforces nothing for live rows, because NULLs do not compare equal and &lt;code&gt;deleted_at&lt;/code&gt; is NULL for every row that is not deleted. Reading the constraint tells you nothing; you have to read the constraint &lt;em&gt;and&lt;/em&gt; the nullability of each of its columns together.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;link table with nothing covering its foreign key pair&lt;/strong&gt; looks like a table. The foreign keys are present and correct, and they check that the referenced rows exist - they have no opinion about how many times the same pairing appears.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;default that its own column's check constraint forbids&lt;/strong&gt; is two objects created in two statements, and every insert relying on the default fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are subtle. They are all mechanical to detect. They survive because the place where a schema is normally read - a migration diff, a dump, a review comment - shows one object at a time, and each of these is a statement about two.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a schema linter checks, and what a migration linter checks
&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;Migration linter&lt;/th&gt;
&lt;th&gt;Schema linter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Input&lt;/td&gt;
&lt;td&gt;The SQL statements in one migration&lt;/td&gt;
&lt;td&gt;The whole schema as a model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs&lt;/td&gt;
&lt;td&gt;In CI, per deploy&lt;/td&gt;
&lt;td&gt;While designing, before the migration exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answers&lt;/td&gt;
&lt;td&gt;Is it safe to run this&lt;/td&gt;
&lt;td&gt;Is the schema correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sees&lt;/td&gt;
&lt;td&gt;Locks, rewrites, backwards compatibility&lt;/td&gt;
&lt;td&gt;Relationships between objects, constraint semantics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cannot see&lt;/td&gt;
&lt;td&gt;Anything already in the schema&lt;/td&gt;
&lt;td&gt;Whether applying a change will take the database down&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two are complementary, not rivals, and the honest version of the comparison says so plainly: nothing in a schema linter tells you that your &lt;code&gt;ALTER TABLE&lt;/code&gt; will hold an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock for eleven minutes on a table with forty million rows. That is exactly what Squawk and Eugene are for, and a team shipping migrations to a busy database wants one of them in CI regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Schemity's seventeen rules check
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;Schema lint&lt;/a&gt; in Schemity runs against the diagram you have open, on your machine, with no connection required - every check it makes is a comparison between things already written down, so it needs the schema rather than the server. Seventeen rules ship today, each with a permanent id, and they are grouped by what the finding costs you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fails at runtime&lt;/strong&gt; - &lt;code&gt;fk-type-mismatch&lt;/code&gt; (the &lt;code&gt;int&lt;/code&gt; to &lt;code&gt;bigint&lt;/code&gt; case, and &lt;code&gt;varchar(36)&lt;/code&gt; to &lt;code&gt;uuid&lt;/code&gt;), &lt;code&gt;default-contradicts-check&lt;/code&gt;, and &lt;code&gt;fk-cycle-all-not-null&lt;/code&gt;, where a cycle of &lt;code&gt;NOT NULL&lt;/code&gt; foreign keys - a table referencing itself is the simplest case - leaves no row that can be inserted first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not enforced&lt;/strong&gt; - &lt;code&gt;no-primary-key&lt;/code&gt;, &lt;code&gt;unique-includes-nullable&lt;/code&gt;, &lt;a href="https://schemity.com/blog/postgres-enum-vs-check-constraint-vs-lookup-table/" rel="noopener noreferrer"&gt;&lt;code&gt;enum-like-no-constraint&lt;/code&gt;&lt;/a&gt; for a &lt;code&gt;status&lt;/code&gt; or &lt;code&gt;type&lt;/code&gt; column with nothing restricting its values, &lt;a href="https://schemity.com/blog/postgres-array-column-vs-junction-table/" rel="noopener noreferrer"&gt;&lt;code&gt;fk-array-column&lt;/code&gt;&lt;/a&gt; for a PostgreSQL array holding foreign keys, and two rules for link tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs&lt;/strong&gt; - &lt;code&gt;redundant-index&lt;/code&gt; for an index whose columns are a leading prefix of a wider one, &lt;code&gt;money-as-float&lt;/code&gt;, and &lt;code&gt;timestamp-not-timestamptz&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convention&lt;/strong&gt; - &lt;code&gt;id-not-primary-key&lt;/code&gt;, &lt;code&gt;composite-pk-with-inbound-fks&lt;/code&gt;, &lt;code&gt;nullable-boolean&lt;/code&gt;, &lt;code&gt;created-at-nullable-or-no-default&lt;/code&gt;, and &lt;code&gt;nullable-string-no-uniqueness&lt;/code&gt;, where absence has two spellings because a blank form field submits an empty string rather than NULL.&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%2Fnwn6h1scw6wvt4oy950m.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%2Fnwn6h1scw6wvt4oy950m.webp" alt="The Schemity lint drawer open on its Rules tab beside a dark-theme PostgreSQL ERD, listing every rule id with its own switch: no-primary-key, junction-no-uniqueness, junction-uniqueness-wrong-columns, unique-includes-nullable, fk-cycle-all-not-null, id-not-primary-key, composite-pk-with-inbound-fks, fk-type-mismatch, redundant-index, default-contradicts-check, enum-like-no-constraint, money-as-float, timestamp-not-timestamptz, nullable-boolean, created-at-nullable-or-no-default, fk-array-column and nullable-string-no-uniqueness, all switched on" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every rule is a row with a switch, and the id is the name you see - there is no severity column to argue with and no numeric score. Two of the seventeen are PostgreSQL-only, and rules that do not apply to your engine are hidden from this tab rather than shown switched off, which is why all seventeen appear above on a PostgreSQL 17.5 connection. What is not in the list matters too: there is no rule about whether a foreign key is indexed, so GitLab's reviewers still have that particular checklist item. Seventeen mechanical checks is not a replacement for a database reviewer. It is the part of their job that a machine should have been doing all along.&lt;/p&gt;

&lt;p&gt;The link-table rules are worth singling out, because a naive version of this check is worse than none. &lt;a href="https://schemity.com/doc/auto-junction-tables/" rel="noopener noreferrer"&gt;Both correct junction shapes&lt;/a&gt; are accepted: a composite primary key over the foreign key pair, and a surrogate &lt;code&gt;id&lt;/code&gt; plus a unique constraint over that pair. Uniqueness is read from wherever it actually comes from - a unique constraint, a unique index, a single-field flag, or the primary key itself, the &lt;a href="https://schemity.com/doc/check-constraints-composite-unique/" rel="noopener noreferrer"&gt;same sources behind the U marker on the canvas&lt;/a&gt;. That leaves the two ways a link table genuinely goes wrong: nothing covers the pair, or something covers the wrong columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why findings are grouped by consequence instead of severity
&lt;/h2&gt;

&lt;p&gt;Because a severity scale asks you to accept somebody else's ranking of your schema, and the first time it ranks a deliberate design as &lt;code&gt;HIGH&lt;/code&gt;, you stop reading it.&lt;/p&gt;

&lt;p&gt;Grouping by consequence says what happens instead and lets you decide. A nullable boolean is reported as three states where two were intended, not as a mistake. A unique constraint containing a nullable column is reported as the rows it exempts, &lt;a href="https://schemity.com/blog/unique-constraints-and-nullable-columns/" rel="noopener noreferrer"&gt;which is the sentence you check your intent against&lt;/a&gt; - because the same constraint shape is correct for an optional unique phone number and catastrophic for a soft-delete timestamp, and no linter can tell which one you meant.&lt;/p&gt;

&lt;p&gt;Here is what that looks like on a real schema - 59 entities, 71 relations, PostgreSQL 17.5, seventeen findings:&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%2Ftd7pu9a2kytyridmsz3l.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%2Ftd7pu9a2kytyridmsz3l.webp" alt="The Schemity lint drawer open on its Findings tab. The header reads Lint (17), with groups for Fails at runtime (1) and Not enforced (11). The runtime finding on new_selfs reads that a foreign key requires a row in its own table so the first row cannot be inserted, and suggests making the column nullable to give the root row somewhere to start. Below it, question_slide_responses is reported as a link table with no uniqueness over its foreign key columns, and members carries a finding on the constraint members_tenant_id_sso_id_key saying a multi-column unique contains a nullable column so it does not constrain, with the detail that rows repeating the same values are accepted whenever sso_id is NULL. Each finding offers Show on canvas and Ignore. On the canvas, an orange strip in the margin marks segment_members with a count of 2" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the three findings in order and none of them says "wrong". The self-referencing foreign key finding states the mechanical consequence - there is no row for the first one to point at - and then offers the fix that makes the design work, which is a nullable column giving the root row somewhere to start. The link table one names the pair nothing covers. The unique one names the exact column whose NULLs open the gap. Eleven of the seventeen findings here sit under "Not enforced", which is the group worth dwelling on: every one of those constraints looks correct in a schema dump.&lt;/p&gt;

&lt;p&gt;That is also why every finding carries an &lt;strong&gt;Ignore&lt;/strong&gt; beside its &lt;strong&gt;Show on canvas&lt;/strong&gt;, and why whole rules can be switched off per diagram. Both are saved in the diagram's JSON file, so the decision travels with the schema and shows up in a pull request like any other change - the team records it once, in the place the schema lives, instead of every new reader rediscovering the constraint and re-arguing it. Ignores are keyed by name rather than by internal id, so renaming an entity brings its ignored findings back: the thing you decided about is not quite the thing you have now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a finding appears
&lt;/h2&gt;

&lt;p&gt;In the margin of the diagram, next to the thing concerned. A thin colored strip sits a few pixels to the left of the flagged entity - spanning the full height for a finding about the entity, marking one field's row exactly for a finding about a field, so &lt;code&gt;orders.total&lt;/code&gt; being a float points at &lt;code&gt;total&lt;/code&gt; and not at &lt;code&gt;orders&lt;/code&gt;. An entity carrying more than one finding gets a count beside the strip. The strip is colored by category and each category also has its own fill, solid, hollow or dotted, so the marking survives greyscale printing and reads the same for a color-blind reviewer.&lt;/p&gt;

&lt;p&gt;This is the part that a list in a terminal cannot do. A linter that prints &lt;code&gt;orders.total: money-as-float&lt;/code&gt; hands you a string you then have to translate back into the picture you were just looking at.&lt;/p&gt;

&lt;p&gt;The count badge is the other half of it, and it works with the drawer shut:&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%2F8kghck7tirgiwn4qa2dl.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%2F8kghck7tirgiwn4qa2dl.webp" alt="The same Schemity ERD with the lint drawer closed. The canvas fills the window and carries no lint markings, while the flag button in the toolbar wears a red badge reading 9+. The status bar reads Entities: 59, Relations: 71, Context view: Main, PostgreSQL: 17.5" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Closed, the diagram is just the diagram - no strips, no highlighting, nothing competing with the schema for attention. The badge still reads &lt;code&gt;9+&lt;/code&gt; and takes its color from the most serious category present, so a diagram carrying only naming conventions stays grey and quiet while one that will fail at runtime turns red. A new problem announces itself instead of waiting to be looked for, and looking costs one click.&lt;/p&gt;

&lt;p&gt;Lint runs where the schema is edited, which means the main view. A context view is a focused subset of the main diagram, read-only by design so that arranging a perspective can never change the schema - so a context view is not linted, and neither are database views or read-only diagrams opened without their connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  From a finding to a migration
&lt;/h2&gt;

&lt;p&gt;Lint fixes nothing by itself, and it should not. It reports, you decide, and the change goes through the normal path: edit the ERD, review the generated &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL diff&lt;/a&gt;, apply it. On a populated table, that diff is also where you find out whether the duplicates a missing constraint allowed are already in the data.&lt;/p&gt;

&lt;p&gt;Which puts the check in a different place in time than every other tool here. A migration linter runs in CI, after the design decision has been made and written into a file. A database reviewer runs at merge request time, after the same thing. Reading seventeen rules against the model means the &lt;code&gt;int&lt;/code&gt; that should have been a &lt;code&gt;bigint&lt;/code&gt; gets caught while it is still a column in a diagram, before it is a column in a table with four hundred million rows in it.&lt;/p&gt;

&lt;p&gt;Each rule reports at most 200 findings and says so when it stops, rather than truncating silently - which, on a schema reverse-engineered from a database nobody has linted before, is a number you will meet.&lt;/p&gt;

&lt;p&gt;If you want the neighbouring cases: &lt;a href="https://schemity.com/blog/comparing-staging-and-production-database-schemas/" rel="noopener noreferrer"&gt;the difference between a schema that looks the same in two environments and one that is the same&lt;/a&gt; is the other question a dump cannot answer, &lt;a href="https://schemity.com/blog/on-delete-cascade-is-invisible-in-your-erd/" rel="noopener noreferrer"&gt;referential actions are invisible in most diagrams for exactly the same reason a type mismatch is&lt;/a&gt;, and &lt;a href="https://schemity.com/blog/many-to-many-shouldnt-mean-hand-building-the-junction-table/" rel="noopener noreferrer"&gt;the choice between a composite primary key and a surrogate id on a junction table&lt;/a&gt; is the decision the link-table rules are careful not to make for you.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>postgres</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Postgres Array Column vs Junction Table: What Each One Does to Your ERD</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 13 Aug 2026 04:19:36 +0000</pubDate>
      <link>https://dev.to/tbson87/postgres-array-column-vs-junction-table-what-each-one-does-to-your-erd-2333</link>
      <guid>https://dev.to/tbson87/postgres-array-column-vs-junction-table-what-each-one-does-to-your-erd-2333</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 PostgreSQL array column stores a list in one field and can be several times faster to query than a junction table, but the database cannot put a foreign key on array elements, so an array of IDs is a many-to-many relationship that no ERD can draw. Use an array when the list is a value the row owns, and a junction table when the elements are references to rows that exist on their own. Schemity supports PostgreSQL array types as first-class field types instead of rejecting them, creates the junction table with its foreign keys and composite primary key from one N:N gesture, and turns a later conversion into a migration SQL diff you read before it runs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Use a PostgreSQL array column when the list is a value the row owns, and a junction table when the elements are references to rows that exist independently. The reason is not normalization theory. It is that PostgreSQL cannot put a foreign key on array elements, so an array of IDs is a many-to-many relationship with nothing to enforce it and nothing for a diagram to draw.&lt;/p&gt;

&lt;p&gt;That second half is the part nobody mentions. Every discussion of &lt;code&gt;int[]&lt;/code&gt; versus a join table is argued on query speed and disk size, both of which favour the array more often than the purists admit. Then a year later somebody opens the ERD to understand how the system fits together, and an entire relationship is simply not in the picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an array of IDs disappears from your ERD
&lt;/h2&gt;

&lt;p&gt;An ERD draws a relationship line because there is a foreign key to draw it from. No foreign key, no line. And PostgreSQL will not give you one on an array column: foreign keys compare directly comparable things, and an array is not comparable to a scalar primary key. This is not an oversight waiting on a release. A patch adding &lt;a href="https://www.postgresql.org/message-id/4EB3DF02.4020604%402ndQuadrant.it" rel="noopener noreferrer"&gt;array element foreign keys&lt;/a&gt; - an &lt;code&gt;ELEMENT REFERENCES&lt;/code&gt; column constraint, so &lt;code&gt;FOREIGN KEY (c1, ELEMENT c2) REFERENCES t1 (u1, u2)&lt;/code&gt; would validate every element - was proposed in 2011 and has never landed. The standing advice on the mailing lists is unchanged after fifteen years: write your own trigger on both tables, or use a details table.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;article.tag_ids int[]&lt;/code&gt; records a many-to-many relationship between articles and tags with no constraint behind it. Delete a tag row and the IDs stay in every array that mentioned it, pointing at nothing. There is no &lt;code&gt;ON DELETE&lt;/code&gt; behaviour to configure, because there is no foreign key to configure it on - which puts array columns in the same blind spot as &lt;a href="https://schemity.com/blog/on-delete-cascade-is-invisible-in-your-erd/" rel="noopener noreferrer"&gt;referential actions that never make it into the diagram&lt;/a&gt;, except worse, because here the reference itself does not exist as far as the engine is concerned.&lt;/p&gt;

&lt;p&gt;Then the diagram tool makes it invisible a second way, by refusing to render the type at all. A dbdiagram.io user wrote on November 14 2021 that &lt;a href="https://community.dbdiagram.io/t/postgresql-array-types/1109" rel="noopener noreferrer"&gt;PostgreSQL array types are marked as errors&lt;/a&gt;, calling them "critical to my schemas (and to my continued use of dbdiagram.io)". The official reply three days later was a workaround: quote the type as a string, &lt;code&gt;name "text []"&lt;/code&gt;, so the parser stops complaining. The export side has the same trouble - &lt;a href="https://github.com/holistics/dbml/issues/46" rel="noopener noreferrer"&gt;Postgres export fails on array&lt;/a&gt; has been open in the DBML repository since 2019, because a column declared as &lt;code&gt;variants array&lt;/code&gt; exports as &lt;code&gt;"variants" array&lt;/code&gt;, which PostgreSQL rejects. A column type that has to be smuggled past the parser as a quoted string is not a modelled type.&lt;/p&gt;

&lt;p&gt;The net effect is a diagram that is wrong twice about the same column. The relationship is missing, and the field lies about what it holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I use a Postgres array column or a junction table?
&lt;/h2&gt;

&lt;p&gt;Decide by asking what the elements are, not by asking which one is faster:&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;Array column (&lt;code&gt;int[]&lt;/code&gt;, &lt;code&gt;text[]&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Junction table&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Referential integrity&lt;/td&gt;
&lt;td&gt;None available; a trigger you write and maintain&lt;/td&gt;
&lt;td&gt;Foreign key per parent, enforced by the engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visible in an ERD&lt;/td&gt;
&lt;td&gt;No line, and often not even a correct type&lt;/td&gt;
&lt;td&gt;Two 1:N relationships, drawn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ON DELETE&lt;/code&gt; behaviour&lt;/td&gt;
&lt;td&gt;Nothing to attach it to&lt;/td&gt;
&lt;td&gt;Configurable per foreign key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attributes on the pairing&lt;/td&gt;
&lt;td&gt;Impossible - the element is a bare value&lt;/td&gt;
&lt;td&gt;Any columns you want on the junction row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate entries&lt;/td&gt;
&lt;td&gt;Allowed unless you add a check&lt;/td&gt;
&lt;td&gt;Blocked by the composite primary key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read a whole row with its list&lt;/td&gt;
&lt;td&gt;One row, no join&lt;/td&gt;
&lt;td&gt;One join, or an aggregate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filter by list contents&lt;/td&gt;
&lt;td&gt;Fast with a GIN index&lt;/td&gt;
&lt;td&gt;Join plus index lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared lookup of all values&lt;/td&gt;
&lt;td&gt;No natural place for it&lt;/td&gt;
&lt;td&gt;The parent table is the lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;A list the row owns: free-form tags, a checklist, a set of flags&lt;/td&gt;
&lt;td&gt;A list of references to rows that exist on their own&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The speed column is where arrays earn their reputation, and it deserves an honest number rather than a hand-wave. Crunchy Data benchmarked exactly this on a tagging schema and found the three-tag lookup ran in &lt;a href="https://www.crunchydata.com/blog/tags-aand-postgres-arrays-a-purrfect-combination" rel="noopener noreferrer"&gt;roughly 120ms on an integer array against roughly 950ms on the relational model&lt;/a&gt; - "about seven times faster than the same query on the relational model". Their conclusion is not that arrays win, though. It is that the array models are "faster to query, smaller to store, and simpler to query" while giving up two specific things: "there's no general place to lookup all tags" and "there's no way to create a simple constraint that guarantees integers exist in the tags table".&lt;/p&gt;

&lt;p&gt;Those two sentences are the whole decision. If you need a place to look up all the values, and a guarantee that every stored value is one of them, you have described a table with a foreign key pointing at it.&lt;/p&gt;

&lt;p&gt;One row of that table deserves a footnote: "blocked by the composite primary key" is true of the junction table you get from an N:N gesture, and not automatically true of one that arrived by reverse-engineering a database somebody else designed. A link table with two foreign keys and no key over the pair accepts duplicates exactly as freely as the array does - and if its key is there but includes a nullable column, &lt;a href="https://schemity.com/blog/unique-constraints-and-nullable-columns/" rel="noopener noreferrer"&gt;it covers only the rows where every key column has a value&lt;/a&gt;, which may be the design or may be an accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test: is the element a value or a reference?
&lt;/h2&gt;

&lt;p&gt;The reliable way to tell the two cases apart is to ask whether an element can exist on its own, before and after any row that mentions it.&lt;/p&gt;

&lt;p&gt;A free-form tag typed into a text field is a value. It has no row anywhere, no ID, no attributes, no lifecycle. &lt;code&gt;tags text[]&lt;/code&gt; on the article is an accurate model of that, and normalizing it into &lt;code&gt;tags&lt;/code&gt; plus &lt;code&gt;articles_tags&lt;/code&gt; buys you nothing but two joins and a table that is a list of strings with surrogate keys stapled on.&lt;/p&gt;

&lt;p&gt;A tag that appears in an autocomplete, has a slug, has a colour, has a count, or can be renamed everywhere at once is a reference. It is a row. The moment it is a row, &lt;code&gt;tag_ids int[]&lt;/code&gt; is storing a foreign key in a place a foreign key cannot go, and every fact about the relationship - that it exists, which direction the dependency runs, what happens on delete - drops out of the schema and lives only in whichever service happens to write that array.&lt;/p&gt;

&lt;p&gt;The same test settles the cases that look ambiguous. A &lt;code&gt;permissions text[]&lt;/code&gt; column on a role is a reference in disguise, because permissions are generated from the routing table and must be looked up as a set - which is why &lt;a href="https://schemity.com/blog/design-roles-and-permissions-for-multi-tenant-rbac/" rel="noopener noreferrer"&gt;permissions in a multi-tenant RBAC schema get their own table and junction&lt;/a&gt; rather than an array of strings on the role. A &lt;code&gt;preferred_contact_methods text[]&lt;/code&gt; on a user is a value, because the elements are vocabulary, not rows. And a list of file paths, a set of coordinates, a fixed-length matrix of scores: values, all of them, because you use the array as a whole.&lt;/p&gt;

&lt;p&gt;There is a third option that people reach for arrays instead of, and it is worth naming: when the list is short, closed, and drawn from a fixed vocabulary, the question is really about a value set, and the &lt;a href="https://schemity.com/blog/postgres-enum-vs-check-constraint-vs-lookup-table/" rel="noopener noreferrer"&gt;choice between a Postgres enum, a check constraint and a lookup table&lt;/a&gt; is the one you are actually making. An array of strings with no constraint on the elements is the least legible member of that family, because nothing anywhere records what an element is allowed to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity renders array columns and junction tables
&lt;/h2&gt;

&lt;p&gt;Schemity supports &lt;a href="https://schemity.com/doc/tables-and-fields/" rel="noopener noreferrer"&gt;PostgreSQL array types as ordinary field types&lt;/a&gt;, so a &lt;code&gt;text[]&lt;/code&gt; column is a &lt;code&gt;text[]&lt;/code&gt; column on the canvas rather than a parser error or a quoted string workaround. When you &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;reverse engineer a PostgreSQL or Supabase schema&lt;/a&gt;, the array columns come across as what they are. That matters most for the honest case: when the list genuinely is a value, you want the diagram to say so plainly, not to force you into a junction table just so the tool has something to draw.&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%2F7z28uprao8tejz7ujzuk.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%2F7z28uprao8tejz7ujzuk.webp" alt="Schemity's field editor on the sso_configs entity: allowed_domains typed as TEXT with a separate [ ] array toggle beside the type dropdown, while the canvas behind it renders scopes and allowed_domains as TEXT[] with their {} defaults, and member_roles and roles_pems sit nearby as real junction tables joined by crow's foot lines" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The array is a toggle on the type rather than a type of its own: pick &lt;code&gt;TEXT&lt;/code&gt;, tick &lt;code&gt;[ ]&lt;/code&gt;, and the field reads &lt;code&gt;TEXT[]&lt;/code&gt; on the entity with its &lt;code&gt;{}&lt;/code&gt; default intact, alongside the PK, Unique and Nullable flags any other column gets. The screenshot has both models in one frame. &lt;code&gt;allowed_domains text[]&lt;/code&gt; on &lt;code&gt;sso_configs&lt;/code&gt; is a value the row owns - a list of domain strings that exist nowhere else and are read as a whole - while &lt;code&gt;member_roles&lt;/code&gt; and &lt;code&gt;roles_pems&lt;/code&gt; are drawn as tables, because members, roles and permissions are rows with their own lifecycle. Nothing about the picture forces the first case into the shape of the second.&lt;/p&gt;

&lt;p&gt;For the reference case, the junction table is generated rather than assembled. Drag between the two entities, pick N:N in the &lt;a href="https://schemity.com/doc/relationships/" rel="noopener noreferrer"&gt;relationship&lt;/a&gt; dialog, and &lt;a href="https://schemity.com/doc/auto-junction-tables/" rel="noopener noreferrer"&gt;the junction table is created&lt;/a&gt; with one foreign key per parent, a composite primary key over the two so a pairing cannot repeat, and a name that follows the naming convention set on the connection. It is a real table you can rename and extend, not a notation shortcut - which is the point when the pairing turns out to need a column of its own, an &lt;code&gt;added_at&lt;/code&gt; or an &lt;code&gt;added_by&lt;/code&gt;, the kind of attribute an array element can never carry. The mechanics of that, and the rule for when a junction wants a composite primary key versus its own &lt;code&gt;id&lt;/code&gt;, are covered in &lt;a href="https://schemity.com/blog/many-to-many-shouldnt-mean-hand-building-the-junction-table/" rel="noopener noreferrer"&gt;why many-to-many shouldn't mean hand-building the junction table&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The practical difference on the canvas is that one of these two models is legible from across the room and the other is not:&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;article.tag_ids int[]&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;articles_tags&lt;/code&gt; junction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;On the canvas&lt;/td&gt;
&lt;td&gt;One field on one entity&lt;/td&gt;
&lt;td&gt;Two crow's foot relationships between three entities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What a reader learns&lt;/td&gt;
&lt;td&gt;This column holds some integers&lt;/td&gt;
&lt;td&gt;Articles and tags are many-to-many, and the delete behaviour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What breaks silently&lt;/td&gt;
&lt;td&gt;A deleted tag leaves dangling IDs&lt;/td&gt;
&lt;td&gt;Nothing; the foreign key refuses the delete or cascades deliberately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Converting an array column to a junction table later
&lt;/h2&gt;

&lt;p&gt;Most of these decisions get revisited, usually when the list grows an attribute or somebody finds orphaned IDs. The conversion is mechanical, and it is worth planning as three deploys rather than one.&lt;/p&gt;

&lt;p&gt;Create the junction table with a foreign key to each parent. Backfill it by expanding the array with &lt;code&gt;unnest&lt;/code&gt;, inserting one row per element. Then count the elements that failed to match a parent row, because that number is the thing the array was hiding - it is the size of the integrity gap you have been carrying, and it is almost never zero. Only after the application reads from the junction do you drop the array column, since that is the single irreversible step.&lt;/p&gt;

&lt;p&gt;Schemity handles the schema half of that the way it handles any structural change: you edit the ERD, and it 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 against the database. Dropping a column that is still the only copy of a relationship is exactly the statement that deserves a human reading it first. Because the diagram is a plain JSON file in the workspace folder, the change is also a line in a pull request, so a reviewer sees a relationship appear in the model at the same time the migration appears in the diff - the same loop as &lt;a href="https://schemity.com/blog/stop-hand-translating-between-sql-and-your-erd/" rel="noopener noreferrer"&gt;not hand-translating between SQL and your ERD&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store the list where the model can show it
&lt;/h2&gt;

&lt;p&gt;An ERD is a single source of truth when every structural fact the database enforces is readable from the diagram and nowhere else is needed to understand it. An array of IDs breaks that quietly, because the fact it encodes - these two things are related, many to many - is enforced by nothing and drawn by nothing. The knowledge moves into application code, and from there into whoever happens to remember.&lt;/p&gt;

&lt;p&gt;None of which makes array columns a mistake. They are a good fit for a list of values, they are genuinely faster on the query shapes they suit, and a tool that cannot even render &lt;code&gt;text[]&lt;/code&gt; is failing you rather than protecting you. The mistake is only ever the substitution: using an array to store references because the junction table felt like too much ceremony to build. It is also why an array of foreign keys is one of the things &lt;a href="https://schemity.com/blog/schema-linting-vs-migration-linting/" rel="noopener noreferrer"&gt;a schema linter reports on the diagram&lt;/a&gt;: nothing checks that the referenced rows exist, so the finding states that consequence rather than calling the column wrong. When the junction table costs one gesture, that trade stops being tempting, and the schema goes back to &lt;a href="https://schemity.com/blog/your-erd-is-hiding-your-domain-model/" rel="noopener noreferrer"&gt;carrying its own domain model&lt;/a&gt; instead of outsourcing half of it to the code that writes the arrays.&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>sql</category>
      <category>architecture</category>
    </item>
    <item>
      <title>DrawSQL Alternative: An Offline Desktop ERD Tool With No Table Limits</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 06 Aug 2026 13:31:08 +0000</pubDate>
      <link>https://dev.to/tbson87/drawsql-alternative-an-offline-desktop-erd-tool-with-no-table-limits-1462</link>
      <guid>https://dev.to/tbson87/drawsql-alternative-an-offline-desktop-erd-tool-with-no-table-limits-1462</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; DrawSQL is a cloud ERD editor with no connection to your database - schema arrives by pasting CREATE TABLE statements - and every plan meters how many tables you may model, from 15 on the public-only free tier to 100 on the $59/month Growth plan. Schemity is the offline desktop alternative: it connects to PostgreSQL, Supabase, MySQL, MariaDB, SQL Server or SQLite directly, re-syncs the schema every time you open the diagram, and stores each diagram as a plain JSON file in your Git repo for $129 one time, with no cap on tables, diagrams, or workspaces.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you like the way DrawSQL works but want it without a browser tab, a subscription, or a counter on how many tables you are allowed to draw, the alternative is a desktop ERD tool that opens a real connection to the database you are diagramming. That is what Schemity is: connect, reverse engineer, model, review the migration, and keep the file in Git.&lt;/p&gt;

&lt;p&gt;DrawSQL is a genuinely nice product. It is developer-shaped rather than a general diagramming canvas, the editor is fast, and the public schema gallery is a good piece of community work. But three structural facts decide whether it fits your work, and none of them is a matter of taste: it has no live database connection at all, the size of the model you are allowed to build is a pricing tier, and the canvas will happily accept a diagram that no database could ever accept.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;DrawSQL&lt;/strong&gt; is a browser-based database diagram editor for developers and teams. Schema goes in by pasting SQL, diagrams live in DrawSQL's cloud, and the collaboration layer - multiplayer editing with live cursors, canvas discussions, version history - is the reason to be there. It supports MySQL, PostgreSQL, SQL Server, and MariaDB, and it exports to SQL, JSON, Laravel migrations, and images.&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%2Fet2u7bcvo1czlu9l56bi.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%2Fet2u7bcvo1czlu9l56bi.webp" alt="A DrawSQL diagram of a StayBnB schema in the browser: tables grouped into colored regions labeled Listings, Users and Trust, and Bookings and Payments, two live collaborator cursors labeled Avy S and Gigi L, yellow sticky notes asking whether cancellation_policies should be a separate table or a JSON field, a left sidebar listing tables with the messages table expanded for inline column editing, and a zoom control reading 60%" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That screenshot is DrawSQL at its best, and it is worth looking at before any criticism lands: table groups as colored regions, sticky notes carrying open design questions, and two teammates' cursors moving on the canvas while you work. Those are real strengths and none of them is on the next screenshot.&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 a SQL migration diff when you change the model, and writes every diagram as plain JSON into a folder you choose. One $129 purchase, no account, no seats, no table limit.&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%2Fijpkz8w30frh5zzabi2e.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%2Fijpkz8w30frh5zzabi2e.webp" alt="A Schemity ERD reverse-engineered from a live PostgreSQL database on macOS: entities grouped into named legends called policy, db_view and targeting, monospace field lists showing types and defaults such as TIMESTAMPTZ NOW() and BOOLEAN FALSE, red U badges for unique constraints and green N badges for nullable fields, database views rendered in italics with mview and view tokens in their footers, crow's foot relationships, small triangles in the top-right corner of the policy legend and of entities that carry a markdown description, a minimap in the corner, and a status bar reading Entities: 59, Relations: 71, Context view: Main, PostgreSQL: 17.5" width="800" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same job, different premise. Every type, default, unique constraint, and nullable flag in that diagram was read from a live database rather than typed in, the two italicised entities are database views the tool knows are read-only, and the footer reports the size of the model as information rather than as a quota. The small triangles in the top-right corner of the &lt;code&gt;policy&lt;/code&gt; legend and of entities like &lt;code&gt;pwd_rules&lt;/code&gt; and &lt;code&gt;segments&lt;/code&gt; are the equivalent of those sticky notes, with one difference this post comes back to below: they are attached to the objects rather than to the canvas.&lt;/p&gt;

&lt;p&gt;The difference is not cloud versus desktop. It is that one tool is fed by copy-paste and metered by plan, and the other is fed by a connection and bounded only by your disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does DrawSQL Connect to Your Database?
&lt;/h2&gt;

&lt;p&gt;No. The only documented way in is text. DrawSQL's own feature list offers &lt;a href="https://drawsql.app/features" rel="noopener noreferrer"&gt;"Import from SQL"&lt;/a&gt; - you paste &lt;code&gt;CREATE TABLE&lt;/code&gt; statements, or run its SQL-to-ERD tool - and there is no connection, no credential store, and no reverse-engineering step anywhere in the product. Nothing on the page tests, saves, or re-reads a database.&lt;/p&gt;

&lt;p&gt;Read that as a workflow and the consequence is not the paste itself, which takes a minute. It is that the diagram is a photograph. The moment a migration lands, the picture and the database disagree, and the only repair is to dump the schema again, paste it again, and re-create the arrangement work you did the first time. This is exactly the loop that makes people stop updating diagrams: &lt;a href="https://schemity.com/blog/keeping-your-erd-updated-shouldnt-be-a-second-job/" rel="noopener noreferrer"&gt;keeping the ERD current becomes a second job&lt;/a&gt; with no payoff between updates.&lt;/p&gt;

&lt;p&gt;Schemity starts from 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, check constraints, unique constraints, and the foreign keys as real crow's foot relationships with their &lt;code&gt;ON DELETE&lt;/code&gt; and &lt;code&gt;ON UPDATE&lt;/code&gt; behavior attached. Passwords go to the operating system keychain, never a config file, and SSH connections store a reference to your private key rather than its contents.&lt;/p&gt;

&lt;p&gt;None of which means a database is required. When you create a connection in Schemity, &lt;strong&gt;Conn method&lt;/strong&gt; has a &lt;strong&gt;None&lt;/strong&gt; option: no host, no credentials, no database - you pick a dialect (PostgreSQL, MySQL, SQLite, or SQL Server) and design on an empty canvas. The dialect is not decoration; it decides which types you can choose from and what the generated SQL looks like, so a schema designed against SQL Server is a SQL Server schema rather than a generic drawing you translate later. Greenfield design, an interview whiteboard, a schema for a database that does not exist yet - all of it works with nothing to connect to, and &lt;a href="https://schemity.com/doc/quick-start/" rel="noopener noreferrer"&gt;starting an empty offline diagram&lt;/a&gt; this way is free; the license unlocks the live-database half.&lt;/p&gt;

&lt;p&gt;So the difference is not that Schemity demands a connection and DrawSQL forbids one. It is that Schemity treats the connection as available and DrawSQL has no concept of one - which decides what happens on day 200, not day one.&lt;/p&gt;

&lt;p&gt;Because the connection persists, so does the diagram's accuracy. Re-opening it pulls the current schema: entities you have already positioned keep their layout, dropped tables and columns disappear, and new tables arrive as dashed drafts ready to place - visible in orange on the minimap so you can see exactly where the drift landed. The pasted-dump workflow is still available when you want it, since &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 schemas over from dbdiagram.io. It is just not the only door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can You Draw a Schema DrawSQL Cannot Export?
&lt;/h2&gt;

&lt;p&gt;Yes, and it takes about three gestures. We spent an afternoon in the DrawSQL editor on 4 August 2026 building an ordinary tasks-and-comments schema, and the canvas accepted three things that no relational database would.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A foreign key between two arbitrary columns.&lt;/strong&gt; Drag from &lt;code&gt;users.name&lt;/code&gt; to &lt;code&gt;tenants.name&lt;/code&gt; - two plain &lt;code&gt;varchar&lt;/code&gt; columns, neither of them a key - and DrawSQL draws the relationship without a murmur.&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%2Fv3ofbyvnvayahm7ens13.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%2Fv3ofbyvnvayahm7ens13.webp" alt="A DrawSQL canvas showing a users table and a tenants table joined by a dashed relationship line from users.name to tenants.name, both varchar columns; the selected relationship's popover reads users name to tenants name and offers cardinality buttons 1:1, 1:N and N:1 with 1:N highlighted, plus a delete icon" width="800" height="207"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In PostgreSQL, MySQL, and SQL Server alike, a foreign key must reference a primary key or a unique constraint, and &lt;code&gt;tenants.name&lt;/code&gt; is neither. So the line reads as a foreign key, is labelled &lt;code&gt;1:N&lt;/code&gt; like a foreign key, and describes a constraint the database would reject outright. The same held for every pair we tried, including &lt;code&gt;comments.body&lt;/code&gt; pointing at &lt;code&gt;members.role&lt;/code&gt;. Nothing about the shape of the line tells you it is fiction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A relationship that is a label, not a constraint.&lt;/strong&gt; The popover in that screenshot is the whole of what a DrawSQL relationship offers: the two endpoints, three cardinality buttons, and a delete icon. There is no &lt;code&gt;ON DELETE&lt;/code&gt; or &lt;code&gt;ON UPDATE&lt;/code&gt; referential action, no constraint name, and no control over how the connector is routed - the only lever on where a line goes is dragging the tables at either end and hoping. Cardinality here is a label you pick rather than something derived from the schema: &lt;code&gt;1:N&lt;/code&gt; is selected because you clicked &lt;code&gt;1:N&lt;/code&gt;, not because &lt;code&gt;tenants.name&lt;/code&gt; is non-unique. DrawSQL's own &lt;a href="https://drawsql.app/docs" rel="noopener noreferrer"&gt;documentation index&lt;/a&gt; matches that thinness - articles for Tables, Columns, Indexes, Table groups, and Sticky notes, and not one for relationships.&lt;/p&gt;

&lt;p&gt;Count those three buttons again, because the set is odd in both directions. &lt;code&gt;1:N&lt;/code&gt; and &lt;code&gt;N:1&lt;/code&gt; are the same relationship read from opposite ends, so one of the three is a duplicate of another - and the missing fourth is &lt;code&gt;N:N&lt;/code&gt;, the only cardinality that actually needs help from a tool, because it is the one a relational database cannot store without a junction table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two tables with the same name.&lt;/strong&gt; Copy an entity, paste it, and the copy lands as a peer rather than as &lt;code&gt;users_copy&lt;/code&gt; or a name collision warning.&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%2Fl4s93nwnwga4nf0e5pqp.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%2Fl4s93nwnwga4nf0e5pqp.webp" alt="A DrawSQL canvas showing two identical tables both named users, each with id bigint, email varchar and name varchar, stacked one above the other, and each drawing its own relationship line into the name column of a third table called tenants" width="800" height="397"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two tables called &lt;code&gt;users&lt;/code&gt;, each with its own &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, and &lt;code&gt;name&lt;/code&gt;, and each running its own relationship into &lt;code&gt;tenants.name&lt;/code&gt;. &lt;code&gt;CREATE TABLE users&lt;/code&gt; twice in one schema is invalid in every engine DrawSQL supports, so the diagram now describes something that cannot be built - and it describes it twice, with two conflicting foreign keys into the same non-key column. The canvas raises nothing, because nothing on the canvas is checking.&lt;/p&gt;

&lt;p&gt;These are the same fact wearing three costumes, and it connects directly to the section above: a tool that never opens a connection never has to produce a schema a database would accept, so nothing in it is under pressure to enforce the rules. To be fair, DrawSQL is partly a sketching tool with a public gallery attached, and permissiveness in a sketch pad is a defensible design choice - you should be able to draw a half-formed idea. But "SQL" is in the name and DDL export is a listed feature, which sets a different expectation: that what you draw is a schema, not a picture that resembles one. We could not test what the export actually emits for that &lt;code&gt;name&lt;/code&gt; to &lt;code&gt;name&lt;/code&gt; line, because export is gated behind a paid plan; the observation is what the editor accepts.&lt;/p&gt;

&lt;p&gt;Schemity treats a relationship as an object with rules, which removes all three cases by construction. You do not connect two arbitrary fields - you drag between two entities and Schemity creates the foreign key field on the child pointing at the parent's primary key, so &lt;code&gt;name&lt;/code&gt; to &lt;code&gt;name&lt;/code&gt; is not expressible in the first place. &lt;a href="https://schemity.com/doc/relationships/" rel="noopener noreferrer"&gt;Every relationship then opens&lt;/a&gt;: cardinality is derived rather than chosen, following the foreign key's uniqueness, which is why a 1:1 does not &lt;a href="https://schemity.com/blog/why-erd-tools-draw-1-1-relationships-as-1-n/" rel="noopener noreferrer"&gt;silently import as a 1:N&lt;/a&gt;; a &lt;code&gt;CASCADE&lt;/code&gt; set in that dialog is then drawn with a bold crow's foot, so its blast radius is visible on the canvas without opening anything. Clicking a relationship highlights the whole line together with both fields it connects, and &lt;a href="https://schemity.com/doc/custom-waypoints/" rel="noopener noreferrer"&gt;dragging it adds a waypoint&lt;/a&gt; so &lt;a href="https://schemity.com/blog/you-shouldnt-have-to-guess-which-line-goes-where/" rel="noopener noreferrer"&gt;you never have to guess which line goes where&lt;/a&gt;. Duplicate entity names are rejected rather than drawn.&lt;/p&gt;

&lt;p&gt;Put the two dialogs side by side and the difference is legible in a glance.&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%2F14p4zn1l1qtv09rvu3ep.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%2F14p4zn1l1qtv09rvu3ep.webp" alt="Schemity's Relation dialog open over an ERD: the relationship is shown as a fixed pwd_rules.id to pwd_policies. prefix followed by an editable foreign key field name pwd_rule_id, dropdowns for On delete and On update both set to NO ACTION, a cardinality selector offering 1:N, 1:1 and N:N with 1:N selected, and Cancel and Save buttons" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note what is editable and what is not. The left side reads &lt;code&gt;pwd_rules.id→pwd_policies.&lt;/code&gt; and cannot be changed - the parent's primary key and the child table are settled by the drag that created the relationship. The one text field is the name of the foreign key column on the child, &lt;code&gt;pwd_rule_id&lt;/code&gt;, because that is the only part of a foreign key that is genuinely a naming decision. Below it sit the two dropdowns DrawSQL has no equivalent for - &lt;code&gt;On delete&lt;/code&gt; and &lt;code&gt;On update&lt;/code&gt;, defaulting to &lt;code&gt;NO ACTION&lt;/code&gt; - which decide how far a &lt;code&gt;DELETE&lt;/code&gt; travels through the schema and are otherwise &lt;a href="https://schemity.com/blog/on-delete-cascade-is-invisible-in-your-erd/" rel="noopener noreferrer"&gt;invisible in most diagrams&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The cardinality menu differs the same way. Schemity offers &lt;code&gt;1:1&lt;/code&gt;, &lt;code&gt;1:N&lt;/code&gt;, and &lt;code&gt;N:N&lt;/code&gt;, and there is no &lt;code&gt;N:1&lt;/code&gt; on purpose: direction is carried by the gesture, so you drag from the parent to the child and the foreign key field is created on the child. That is not a shortcut, it is where the answer already lives - in a real schema the direction of a relationship is not a preference, it is a fact about which table holds the foreign key column. Offering &lt;code&gt;1:N&lt;/code&gt; and &lt;code&gt;N:1&lt;/code&gt; as separate settings splits one fact into two representations and leaves room for them to disagree; drag the other way in Schemity and you get the other relationship, because you have built a different schema. Meanwhile the &lt;code&gt;N:N&lt;/code&gt; that DrawSQL's popover has no button for is &lt;a href="https://schemity.com/doc/auto-junction-tables/" rel="noopener noreferrer"&gt;the one Schemity builds the junction table for&lt;/a&gt; - both foreign keys and a composite primary key already in place - rather than leaving you to &lt;a href="https://schemity.com/blog/many-to-many-shouldnt-mean-hand-building-the-junction-table/" rel="noopener noreferrer"&gt;assemble it by hand in six steps&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And when the diagram is connected to a database, the migration diff is the final check - the model has to be something the engine will actually accept before it becomes real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does DrawSQL Cost in 2026?
&lt;/h2&gt;

&lt;p&gt;Verified from &lt;a href="https://drawsql.app/pricing" rel="noopener noreferrer"&gt;DrawSQL's pricing page&lt;/a&gt; on 4 August 2026, at the default monthly billing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Private diagrams&lt;/th&gt;
&lt;th&gt;Tables per diagram&lt;/th&gt;
&lt;th&gt;Users&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;None - all diagrams public&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Starter&lt;/td&gt;
&lt;td&gt;$19/mo or $171/yr&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Growth&lt;/td&gt;
&lt;td&gt;$59/mo or $531/yr&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large&lt;/td&gt;
&lt;td&gt;$179/mo or $1,790/yr&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two numbers in that table do the most work. The first is 15 - the free tier's table cap, which arrives long before a real schema does. The second is $179 per month, the price at which the cap disappears entirely. Between them, the plan you are on decides how much of your database you are permitted to model, which is a strange thing to negotiate with a drawing tool. Modeling under a quota bends the model: &lt;a href="https://schemity.com/blog/your-erd-tool-shouldnt-count-your-tables/" rel="noopener noreferrer"&gt;you merge entities you would have split and skip the junction table you actually needed&lt;/a&gt;, and the diagram quietly stops being an honest description of the system.&lt;/p&gt;

&lt;p&gt;Look back at the Schemity status bar in the screenshot above: &lt;strong&gt;Entities: 59, Relations: 71&lt;/strong&gt;. That is an unremarkable application schema - not a warehouse, not an enterprise ERP - and it is already nearly four free tiers' worth of tables, nine past the Starter cap, and squarely in $59-per-month Growth territory. The status bar reports it the way a text editor reports a line count, next to the PostgreSQL version, because the number is information rather than a threshold.&lt;/p&gt;

&lt;p&gt;Schemity is $129 once, for unlimited workspaces, unlimited diagrams, unlimited entities, and unlimited context views. That is less than one year of the cheapest paid DrawSQL plan at its annual rate, and about 2.2 months of Growth. There is no per-seat multiplier, because there are no seats - the files are on your disk and your teammates read them out of the repository.&lt;/p&gt;

&lt;p&gt;AI is priced differently too. DrawSQL includes 500 AI credits per month on Starter and Growth, so the assistant is a metered feature of the subscription. Schemity's &lt;a href="https://schemity.com/doc/byok-setup/" rel="noopener noreferrer"&gt;built-in chatbot is bring-your-own-key&lt;/a&gt;: you point it at OpenAI, Claude, Gemini, Grok, or DeepSeek with your own key and pay that provider directly, or you &lt;a href="https://schemity.com/blog/ai-database-design-without-the-cloud/" rel="noopener noreferrer"&gt;add Ollama and run a local model with no cloud in the loop at all&lt;/a&gt;. Either way the credits are not ours to sell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens to Your Schema on the Free Tier?
&lt;/h2&gt;

&lt;p&gt;It becomes a public URL. DrawSQL's free plan is unlimited &lt;em&gt;public&lt;/em&gt; diagrams - privacy is what the $19/month Starter plan buys. That is a legitimate business model, and for the open-source schemas in DrawSQL's gallery it is the whole point. It is also a hard stop for a large class of professional work.&lt;/p&gt;

&lt;p&gt;If you consult, the schema on your canvas belongs to a client and is very likely covered by an NDA. If you work somewhere with an IT policy, "the ERD is on a public web page" is not a sentence that survives review. The free tier is therefore not a free tier for you - it is a paywall with a publication clause, and the first real decision you make in the tool is which client's data model you are willing to publish. That is the same problem as &lt;a href="https://schemity.com/blog/your-clients-schema-doesnt-belong-in-your-cloud-account/" rel="noopener noreferrer"&gt;keeping every client's schema in one shared cloud account&lt;/a&gt;, with the sharing default turned all the way up.&lt;/p&gt;

&lt;p&gt;An offline ERD tool inverts the question, and it inverts it for free diagrams too - designing with Conn method None costs nothing and still writes to your disk rather than to a URL. A Schemity workspace is a folder of JSON files on your machine; a diagram is not published, shared, or hosted unless you put it somewhere yourself. Per-client isolation is physical - one folder per engagement - and handover at the end of a contract is a folder copy rather than a permissions audit. Nothing needs to be public for you to have the free use of your own model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Does the Diagram Live?
&lt;/h2&gt;

&lt;p&gt;In DrawSQL, a diagram is a record in DrawSQL's database, reachable through DrawSQL's interface, for as long as your subscription is current. Exports exist, but on Starter the export is JSON only - so the cheapest paid plan is also the one where getting your work back out is most constrained. When your model lives inside a vendor's storage, &lt;a href="https://schemity.com/blog/switching-erd-tools-shouldnt-mean-starting-over/" rel="noopener noreferrer"&gt;switching tools later means re-doing the modeling&lt;/a&gt;, and the diagram itself is &lt;a href="https://schemity.com/blog/your-erd-shouldnt-be-able-to-just-disappear/" rel="noopener noreferrer"&gt;something that can go missing without you deleting anything&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In Schemity, a diagram is a file. Creating a workspace creates a matching folder, every diagram inside it is &lt;a href="https://schemity.com/doc/json-storage-format/" rel="noopener noreferrer"&gt;plain, readable JSON&lt;/a&gt;, and you open that folder in Finder or Explorer with one shortcut. Put the folder in a repository and the schema gets the same treatment as the code: &lt;a href="https://schemity.com/blog/erd-lives-in-your-git-repo/" rel="noopener noreferrer"&gt;the ERD is reviewed in a pull request&lt;/a&gt; as a diff, blamed, branched, and reverted with the tools you already use. Workspaces that sit inside a Git repository even show a branch icon in the workspace list, so which diagrams are actually under version control is visible rather than assumed.&lt;/p&gt;

&lt;p&gt;That distinction is worth testing against the unhappy case, because a tool's real character shows when you stop paying it. When Schemity's two-week trial expires, context views and the Context Map are locked - the pricing page says so, and Schemity Lite does not include them either. What does not happen is your work becoming unreachable. The context views you already built are still in the workspace JSON on your disk, still in your Git history, still readable in any text editor, and still there the moment a license unlocks the panel again. Expiry withdraws a feature from the application; it does not withdraw the file from you. That is a different kind of event from a lapsed subscription in a cloud tool, where what ends is access to the diagram itself.&lt;/p&gt;

&lt;p&gt;Export is not a rescue operation either. A diagram or a single context view goes out as JPG, PNG, or SVG; the SQL of the main view, any context view, or one entity is a right-click away; and the whole schema exports as DBML or as a Mermaid &lt;code&gt;erDiagram&lt;/code&gt; that GitHub, GitLab, Notion, and Obsidian render natively from text.&lt;/p&gt;

&lt;h2&gt;
  
  
  DrawSQL vs Schemity
&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;DrawSQL&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;Cloud database diagram editor for teams&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, 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;Paste &lt;code&gt;CREATE TABLE&lt;/code&gt; statements&lt;/td&gt;
&lt;td&gt;Live connection or SSH tunnel; SQL dump and DBML import too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Designing with no database&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The only mode there is&lt;/td&gt;
&lt;td&gt;Conn method None, with a chosen dialect driving types and SQL&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;MySQL, PostgreSQL, SQL Server, MariaDB&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;Keeping it current&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Re-paste the schema&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;Relationships&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Endpoints, three cardinality buttons, delete&lt;/td&gt;
&lt;td&gt;Cardinality derived from uniqueness, &lt;code&gt;ON DELETE&lt;/code&gt;/&lt;code&gt;ON UPDATE&lt;/code&gt;, waypoints, click-to-highlight&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cardinalities offered&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;1:1&lt;/code&gt;, &lt;code&gt;1:N&lt;/code&gt;, &lt;code&gt;N:1&lt;/code&gt; - no &lt;code&gt;N:N&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;1:1&lt;/code&gt;, &lt;code&gt;1:N&lt;/code&gt;, &lt;code&gt;N:N&lt;/code&gt; - direction comes from the drag, so no &lt;code&gt;N:1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Many-to-many&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Build the junction table yourself&lt;/td&gt;
&lt;td&gt;Junction table generated with both FKs and a composite primary key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;What the canvas allows&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Foreign keys between arbitrary columns; duplicate table names&lt;/td&gt;
&lt;td&gt;Foreign keys target the primary key; duplicate entity names rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Notes on the diagram&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sticky notes, anchored to a canvas position&lt;/td&gt;
&lt;td&gt;Markdown descriptions, anchored to an entity, legend, or context view&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;Table limit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;15 free, 50 Starter, 100 Growth, unlimited at $179/mo&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Privacy of a free diagram&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Public, by design&lt;/td&gt;
&lt;td&gt;Private - free offline design still writes to your disk, not a server&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;Records in DrawSQL'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;Version history&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built in, on paid plans&lt;/td&gt;
&lt;td&gt;Git, on files you own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;500 credits/month on paid plans&lt;/td&gt;
&lt;td&gt;BYOK (OpenAI, Claude, Gemini, Grok, DeepSeek) or local Ollama&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, canvas discussions&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;$19 to $179/month, per plan and per seat&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;
  
  
  Modeling a Schema That Does Not Fit in One Diagram
&lt;/h2&gt;

&lt;p&gt;The table cap and the wall-poster problem are the same problem seen from two ends. A 200-table database will not fit on any tier below $179 per month, and even when it fits, one canvas holding 200 tables is not something anyone reads.&lt;/p&gt;

&lt;p&gt;Schemity's answer is not a bigger canvas but more perspectives on one model. Legends group related entities into named, colored regions on the main view. &lt;a href="https://schemity.com/doc/context-views/" rel="noopener noreferrer"&gt;Context views&lt;/a&gt; then 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. Right-click a legend and every entity inside it becomes a context view in one step. An entity with a relationship to something outside the view is marked with an orange dot, so a focused view still tells you what it is hiding.&lt;/p&gt;

&lt;p&gt;Above those, the Context Map renders each context view as a single node and draws arrows for the foreign keys flowing between them, with a count on every arrow and a curved arrow wherever two contexts depend on each other. Fifteen readable diagrams and one architecture map beat a single sheet nobody opens - and the reason &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; is that comprehension never scaled with the canvas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Should a Note About a Table Live?
&lt;/h2&gt;

&lt;p&gt;Attached to the table, is Schemity's answer - which is why it has entity, legend, and context view descriptions but deliberately no sticky notes.&lt;/p&gt;

&lt;p&gt;The difference is what the note is anchored to. A sticky note is anchored to a coordinate on the canvas: it sits at x, y, and the canvas is the only thing that knows it exists. A description is anchored to an object. Give &lt;code&gt;pwd_rules&lt;/code&gt; a description and a triangle appears in its top-right corner; clicking it opens the rendered markdown. Legends carry them too, so the note can describe a whole domain rather than one table, and so can a context view.&lt;/p&gt;

&lt;p&gt;That distinction is not aesthetic, because the two behave differently as the schema moves underneath them:&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;Sticky note on the canvas&lt;/th&gt;
&lt;th&gt;Description on the object&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;You move the table&lt;/td&gt;
&lt;td&gt;Note stays where it was&lt;/td&gt;
&lt;td&gt;Travels with the table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The table is dropped and you re-sync&lt;/td&gt;
&lt;td&gt;Note survives, pointing at nothing&lt;/td&gt;
&lt;td&gt;Removed with the table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The table is imported into a context view&lt;/td&gt;
&lt;td&gt;Does not come along&lt;/td&gt;
&lt;td&gt;Comes with it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Someone reads the diff&lt;/td&gt;
&lt;td&gt;A note moved, somewhere&lt;/td&gt;
&lt;td&gt;The note changed on the entity it describes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six months on, an orphaned note floating near where a table used to be is worse than no note, because it reads as documentation and it is actually a fossil. A description cannot outlive its subject: it is a property of the entity, stored in the entity's JSON, so &lt;a href="https://schemity.com/blog/the-data-dictionary-should-live-in-the-erd/" rel="noopener noreferrer"&gt;the meaning of the schema and the schema itself move as one thing&lt;/a&gt; - which is the entire point of putting the data dictionary inside the diagram rather than in a wiki beside it.&lt;/p&gt;

&lt;p&gt;The honest cost is the case DrawSQL's own screenshot demonstrates. That sticky note asks whether &lt;code&gt;cancellation_policies&lt;/code&gt; should be a separate table or a JSON column on &lt;code&gt;listings&lt;/code&gt; - a question about a table that does not exist yet, and therefore has nothing to attach a description to. Anchoring to objects means you cannot pin a thought to a decision you have not made. Schemity's partial answer is the legend: draw one around the area under discussion and its description holds the open question for that domain. The full answer is that an unresolved design question is not documentation, and probably belongs in the pull request or the ticket where it will be resolved and closed - which is the same reasoning behind &lt;a href="https://schemity.com/blog/why-schemity-has-no-zoom/" rel="noopener noreferrer"&gt;Schemity having no zoom&lt;/a&gt;. Some absences are decisions.&lt;/p&gt;

&lt;p&gt;If your team thinks out loud on the canvas, that is a real reason to prefer DrawSQL, and no amount of philosophy changes it.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;Real-time multiplayer editing.&lt;/strong&gt; Live cursors, simultaneous edits, and discussion threads pinned to the canvas. Schemity does not do this and does not plan to - it takes a deliberate single-designer position where one person holds the pen and feedback arrives through review. If your modeling sessions are three people on one canvas, DrawSQL is built for that and Schemity is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sticky notes on the canvas.&lt;/strong&gt; As the section above argues, Schemity's descriptions are the more durable form - but durability is not what a half-formed thought needs. If your team parks open questions on the diagram as you go, DrawSQL supports that directly and Schemity asks you to put them somewhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A link, not an install.&lt;/strong&gt; Anyone with the URL can open the diagram - no download, no license, no operating system to match. Schemity's read-only mode lets someone explore a diagram without database access, but it is still an app they have to install.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel migration export.&lt;/strong&gt; Exporting the model straight to Laravel migrations is a genuinely useful piece of framework integration that Schemity has no equivalent for; Schemity emits SQL DDL and a migration diff instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A free way in.&lt;/strong&gt; Fifteen tables and a public URL is a real starting point if the schema is not confidential - and for the open-source schemas in DrawSQL's gallery, publishing is the feature. Schemity's free paths are a two-week full trial on the desktop app, offline design with Conn method None after it, and Schemity Lite in the browser for designing and sharing by link. But they are genuinely narrower: the live-database half this post spends most of its time on is licensed, and so are context views and the Context Map. There is no permanent free tier that includes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is a Good DrawSQL Alternative That Works Offline?
&lt;/h2&gt;

&lt;p&gt;If what you valued in DrawSQL was the shared canvas - people editing together, discussions on the diagram - then no offline tool will replace it, and you should keep using it for that.&lt;/p&gt;

&lt;p&gt;If what you valued was the ERD itself, the useful alternative is a tool whose primitives are tables, foreign keys, and migrations, and whose storage is a file. Schemity is that in desktop form: it connects to the database and reads the constraints instead of waiting for a paste, it re-syncs on open so the diagram stays true without a maintenance ritual, it generates a &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL diff&lt;/a&gt; you approve before anything runs, and it keeps every diagram as a JSON file &lt;a href="https://schemity.com/doc/version-control-git/" rel="noopener noreferrer"&gt;that Git can version&lt;/a&gt; alongside the code it describes.&lt;/p&gt;

&lt;p&gt;And the arithmetic is not close. A subscription is a permanent line item that also decides how large your model may be; a $129 one-time purchase ERD tool with unlimited local workspaces has neither property.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Diagramming is a live team activity and multiplayer editing is the point&lt;/li&gt;
&lt;li&gt;Sharing means sending a URL to people who will not install anything&lt;/li&gt;
&lt;li&gt;Your schemas are open source, or public visibility is genuinely fine&lt;/li&gt;
&lt;li&gt;Laravel migration export fits your stack directly&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;Or the database does not exist yet, and you want to design it against a real dialect before it does&lt;/li&gt;
&lt;li&gt;The schema is under NDA, an IT policy, or simply none of the internet's business&lt;/li&gt;
&lt;li&gt;The ERD should live in Git, be reviewed in pull requests, and outlive any vendor&lt;/li&gt;
&lt;li&gt;Your model is bigger than a free tier and you refuse to negotiate its size&lt;/li&gt;
&lt;li&gt;One purchase, no seats, no meter&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;DrawSQL is a well-made cloud ERD editor, and both of its limits follow from that description rather than from any lack of craft: a browser app with no connection to your database has to be fed by hand, and a subscription product has to meter something, so it meters tables.&lt;/p&gt;

&lt;p&gt;If your diagram is a conversation, that trade is fine. If your diagram is a picture of a database you have to keep accurate for years, the fix is an offline desktop ERD tool that opens the connection, reads the constraints, preserves your layout on every re-sync, and writes the result to a file you own.&lt;/p&gt;

&lt;p&gt;Comparing the other 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;li&gt;
&lt;strong&gt;The general diagramming suite:&lt;/strong&gt; see &lt;a href="https://schemity.com/blog/lucidchart-erd-alternative/" rel="noopener noreferrer"&gt;Lucidchart ERD 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>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>
  </channel>
</rss>
