<?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>Forem: Son Tran</title>
    <description>The latest articles on Forem by Son Tran (@tbson87).</description>
    <link>https://forem.com/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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F79995%2F6de9d37a-5521-4b7a-a835-9787e74caf0c.jpg</url>
      <title>Forem: Son Tran</title>
      <link>https://forem.com/tbson87</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/tbson87"/>
    <language>en</language>
    <item>
      <title>You Have Been Reading ERD Notation Wrong</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Mon, 13 Apr 2026 01:58:10 +0000</pubDate>
      <link>https://forem.com/tbson87/you-have-been-reading-erd-notation-wrong-b1o</link>
      <guid>https://forem.com/tbson87/you-have-been-reading-erd-notation-wrong-b1o</guid>
      <description>&lt;p&gt;If you have ever designed a database schema, you have probably drawn an ERD with crow's foot notation. You learned that a circle means optional, a line means mandatory, and a crow's foot means many. You felt confident.&lt;/p&gt;

&lt;p&gt;Then you opened your SQL editor and had to guess whether the FK should be &lt;code&gt;NULL&lt;/code&gt; or &lt;code&gt;NOT NULL&lt;/code&gt;. You were not sure if you needed &lt;code&gt;UNIQUE&lt;/code&gt;. And nobody told you that one of the notation symbols cannot be enforced in SQL at all.&lt;/p&gt;

&lt;p&gt;This article fixes that. We will cover what each symbol actually means, how to translate notation directly into SQL constraints, and where the notation is richer than what your database can enforce.&lt;/p&gt;

&lt;p&gt;Throughout this article, we use one consistent convention: &lt;strong&gt;parent entity is always on the left, child entity is always on the right.&lt;/strong&gt; This makes every notation example unambiguous.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Brief History
&lt;/h2&gt;

&lt;p&gt;The crow's foot symbol was introduced by Gordon Everest in his 1976 paper "Basic Data Structure Models Explained with a Common Example." Everest used an "inverted arrow" to represent the one-to-many relationship in hierarchical and network data structures, noting that it "inherently and visually depicts the one-to-many relationship" without implying a direction of access.&lt;/p&gt;

&lt;p&gt;At that stage, the notation only expressed &lt;strong&gt;maximum degree&lt;/strong&gt;: whether a link could have one or many instances. The minimum degree symbols (the circle and the additional line that express zero vs. one) were added later by other authors and tools as the notation evolved into what we now call crow's foot or Information Engineering (IE) notation.&lt;/p&gt;

&lt;p&gt;This origin matters for one reason: the symbol that most engineers obsess over - the minimum degree symbol - was not even part of the original notation. As we will see, there is a strong argument that it still should not be.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Rule Most Tutorials Miss
&lt;/h2&gt;

&lt;p&gt;Before explaining any symbol, there is one reading rule that almost every tutorial skips:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Symbols are read from the entity outward, in pairs. The symbols closest to an entity describe the constraints on that entity.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the part that trips most engineers up. The symbols on the left side of the line describe the left entity. The symbols on the right side describe the right entity. Not the other way around.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[users] || ----- o&amp;lt; [blogs]
         ↑       ↑
   read from   read from
   users out   blogs out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;||&lt;/code&gt; near users = a blog must have exactly one user (FK NOT NULL)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;o&amp;lt;&lt;/code&gt; near blogs = a user can have zero or many blogs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With that rule clear, everything else follows.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Symbols
&lt;/h2&gt;

&lt;p&gt;Crow's foot notation uses three base symbols:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symbol&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Circle&lt;/td&gt;
&lt;td&gt;Zero (optional)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Line&lt;/td&gt;
&lt;td&gt;One (mandatory)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Crow's foot&lt;/td&gt;
&lt;td&gt;Many&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each end of a relationship line uses a &lt;strong&gt;pair&lt;/strong&gt; of symbols, read left to right as they visually appear. The symbol closest to the entity is the &lt;strong&gt;maximum&lt;/strong&gt;. The symbol closest to the line is the &lt;strong&gt;minimum&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  All Valid Combinations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Parent side (left)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symbol&lt;/th&gt;
&lt;th&gt;Min&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Child must reference this parent. FK is NOT NULL.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮o&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Child optionally references this parent. FK is nullable.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Child side (right)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symbol&lt;/th&gt;
&lt;th&gt;Min&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Exactly one child. Used in mandatory 1:1 relationships.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Zero or one child. Used in optional 1:1 relationships.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;many&lt;/td&gt;
&lt;td&gt;Zero or more children. The most common case.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;many&lt;/td&gt;
&lt;td&gt;One or more children. The parent must have at least one child.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is the complete set. Four combinations on the child side, two on the parent side. No others exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  Notation to SQL: The Complete Mapping
&lt;/h2&gt;

&lt;p&gt;Let us use a concrete domain: &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;profiles&lt;/code&gt;, &lt;code&gt;avatars&lt;/code&gt;, &lt;code&gt;blogs&lt;/code&gt;, &lt;code&gt;addresses&lt;/code&gt;, and &lt;code&gt;attachments&lt;/code&gt;.&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.amazonaws.com%2Fuploads%2Farticles%2Flwawvz9ycubupx0ogylk.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.amazonaws.com%2Fuploads%2Farticles%2Flwawvz9ycubupx0ogylk.png" alt="concrete domain fig"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. users and profiles - &lt;code&gt;||——||&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A user must always have exactly one profile. A profile must always belong to exactly one user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[users] ||——|| [profiles]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"profiles"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nv"&gt;"user_id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"display_name"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"bio"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"created_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"updated_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"profiles"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"profiles_pkey"&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"profiles"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"profiles_user_id_key"&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"profiles"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"profiles_user_id_fkey"&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOT NULL&lt;/code&gt; enforces that a profile must belong to a user&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UNIQUE&lt;/code&gt; enforces that a user can have at most one profile&lt;/li&gt;
&lt;li&gt;"A user must have a profile" is &lt;strong&gt;not enforced by SQL&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. users and avatars - &lt;code&gt;||——o|&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A user can have zero or one avatar. If an avatar exists, it must belong to exactly one user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[users] ||——o| [avatars]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"avatars"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nv"&gt;"user_id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"url"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"created_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"updated_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"avatars"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"avatars_pkey"&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"avatars"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"avatars_user_id_key"&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"avatars"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"avatars_user_id_fkey"&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOT NULL&lt;/code&gt; enforces the avatar must belong to a user&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UNIQUE&lt;/code&gt; still enforces at most one avatar per user&lt;/li&gt;
&lt;li&gt;Note: in SQL, &lt;code&gt;NULL&lt;/code&gt; values are not considered equal, so multiple &lt;code&gt;NULL&lt;/code&gt; values are allowed even with a &lt;code&gt;UNIQUE&lt;/code&gt; constraint&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. users and blogs - &lt;code&gt;||——o&amp;lt;&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A user can have zero or many blogs. A blog must belong to exactly one user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[users] ||——o&amp;lt; [blogs]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"blogs"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nv"&gt;"user_id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"slug"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"title"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"description"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"content"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"created_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"updated_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"blogs"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"blogs_pkey"&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"blogs"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"blogs_slug_key"&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"slug"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"blogs"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"blogs_title_key"&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"blogs"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"blogs_user_id_fkey"&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOT NULL&lt;/code&gt; enforces that every blog must have an owner&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;UNIQUE&lt;/code&gt; because one user can have many blogs&lt;/li&gt;
&lt;li&gt;This is the most common pattern in relational schemas&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. users and addresses - &lt;code&gt;||——|&amp;lt;&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A user must have at least one address. An address must belong to exactly one user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[users] ||——|&amp;lt; [addresses]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"addresses"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nv"&gt;"user_id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"line_1"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"line_2"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"postcode"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"created_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"updated_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"addresses"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"addresses_pkey"&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"addresses"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"addresses_user_id_fkey"&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOT NULL&lt;/code&gt; enforces that every address must belong to a user&lt;/li&gt;
&lt;li&gt;"A user must have at least one address" is &lt;strong&gt;not enforced by SQL&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. blogs and attachments - &lt;code&gt;|o——o&amp;lt;&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;An attachment can optionally belong to a blog. A blog can have zero or many attachments.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[blogs] |o——o&amp;lt; [attachments]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"attachments"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nv"&gt;"blog_id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"url"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"created_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"updated_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"attachments"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"attachments_pkey"&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"attachments"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"attachments_blog_id_fkey"&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"blog_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nv"&gt;"blogs"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;|o&lt;/code&gt; near blogs = FK is nullable, an attachment can exist without a blog&lt;/li&gt;
&lt;li&gt;This is the nullable FK case that most tutorials never explain&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Full Mapping Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Notation&lt;/th&gt;
&lt;th&gt;Side&lt;/th&gt;
&lt;th&gt;FK nullable?&lt;/th&gt;
&lt;th&gt;UNIQUE?&lt;/th&gt;
&lt;th&gt;Reference status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent (left)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NOT NULL REFERENCES&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮o&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent (left)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NULL REFERENCES&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child (right, 1:1)&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child (right, 1:1)&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child (right)&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child (right)&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Gap: What SQL Cannot Enforce
&lt;/h2&gt;

&lt;p&gt;Here is the part that most tutorials never tell you.&lt;/p&gt;

&lt;p&gt;Two of the six notation cases produce &lt;strong&gt;identical SQL&lt;/strong&gt; but mean different things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Notation&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Reference status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Zero or more children&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NOT NULL REFERENCES&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One or more children&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NOT NULL REFERENCES&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The SQL is the same. The &lt;code&gt;|&amp;lt;&lt;/code&gt; minimum of one is purely a business rule. SQL has no constraint that says "this parent must have at least one child row."&lt;/p&gt;

&lt;p&gt;Similarly, &lt;code&gt;||——||&lt;/code&gt; (user must have exactly one profile) is only half-enforced. The FK &lt;code&gt;NOT NULL UNIQUE&lt;/code&gt; ensures every profile has a user and each user has at most one profile. But nothing in SQL prevents you from creating a user with no profile at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  The unenforced cases
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Notation&lt;/th&gt;
&lt;th&gt;What SQL enforces&lt;/th&gt;
&lt;th&gt;What SQL cannot enforce&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮⎮——⎮⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Profile must have a user. At most one profile per user.&lt;/td&gt;
&lt;td&gt;User must have a profile.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮⎮——⎮&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Address must have a user.&lt;/td&gt;
&lt;td&gt;User must have at least one address.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In both cases, the gap must be handled at the application level. This is intentional and correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this gap exists by design
&lt;/h3&gt;

&lt;p&gt;This is not a flaw in crow's foot notation. It is a deliberate design decision.&lt;/p&gt;

&lt;p&gt;Carlis and Maguire, in their book &lt;em&gt;Mastering Data Modeling&lt;/em&gt; (Addison-Wesley, 2001), argue explicitly against capturing minimum degree constraints on a data model diagram. Their reasoning is direct: minimum degree constraints "are so often false or are a function of processing" rather than a true property of the data itself.&lt;/p&gt;

&lt;p&gt;They give a concrete example: a hospital clerk reports that "every patient has at least one diagnosis code." This looks like a valid minimum constraint. But it is false - a patient admitted to the emergency room has no diagnosis code yet. The constraint only holds at a specific stage of the process, not at all times. It is a processing rule, not a data rule.&lt;/p&gt;

&lt;p&gt;The same logic applies to your schema. "A user must have at least one address" is almost certainly a processing rule - enforced when a user checks out, or when an order is submitted, not at the moment the user row is created. SQL enforcing it at the database level would break every user registration flow that creates the user first and collects the address later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;Key takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Convention&lt;/td&gt;
&lt;td&gt;Parent on the left, child on the right&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading rule&lt;/td&gt;
&lt;td&gt;Symbols describe the entity they touch, read outward in pairs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;⎮o&lt;/code&gt; vs &lt;code&gt;⎮⎮&lt;/code&gt; on parent&lt;/td&gt;
&lt;td&gt;Nullable FK vs NOT NULL FK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;o⎮&lt;/code&gt; vs &lt;code&gt;⎮⎮&lt;/code&gt; on child&lt;/td&gt;
&lt;td&gt;Identical SQL, only intent differs both 1:1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;o&amp;lt;&lt;/code&gt; vs &lt;code&gt;⎮&amp;lt;&lt;/code&gt; on child&lt;/td&gt;
&lt;td&gt;Identical SQL, only intent differs both 1:N&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The gap&lt;/td&gt;
&lt;td&gt;Minimum degree constraints are often processing rules, not data rules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Crow's foot notation is more expressive than SQL. That is not a flaw, it is by design. The notation captures business intent. SQL enforces what it can. Your application handles the rest.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Everest, G. C. (1976). "Basic Data Structure Models Explained with a Common Example." &lt;em&gt;Proceedings of the Fifth Texas Conference on Computing Systems&lt;/em&gt;, pages 39-46. IEEE Computer Society.&lt;/li&gt;
&lt;li&gt;Carlis, J. and Maguire, J. (2001). &lt;em&gt;Mastering Data Modeling: A User-Driven Approach&lt;/em&gt;. Addison-Wesley.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post uses &lt;a href="https://schemity.com/" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt; as the &lt;a href="https://schemity.com/" rel="noopener noreferrer"&gt;ERD tool&lt;/a&gt; for all database diagrams.&lt;/p&gt;

</description>
      <category>erd</category>
      <category>database</category>
      <category>schema</category>
      <category>databasedesign</category>
    </item>
    <item>
      <title>Design Data Model for Multi-Tenant RBAC</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Mon, 13 Apr 2026 01:51:18 +0000</pubDate>
      <link>https://forem.com/tbson87/design-data-model-for-multi-tenant-rbac-1ghh</link>
      <guid>https://forem.com/tbson87/design-data-model-for-multi-tenant-rbac-1ghh</guid>
      <description>&lt;h2&gt;
  
  
  Part 1: Identity Foundation
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Behavior Description
&lt;/h2&gt;

&lt;p&gt;Assume we are building a KPI management platform. Multiple companies can create accounts (tenants) and let their employees access the platform.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A user can be assigned to one or several roles. Their permissions are the sum of those roles' permissions.&lt;/li&gt;
&lt;li&gt;A user cannot access features outside their permissions. Even if they know the API specs and bypass the UI, they will still be blocked at the API layer.&lt;/li&gt;
&lt;li&gt;Admins can change a user's roles or a role's permissions at any time. The only inconvenience is that the affected user needs to log out and log back in for the changes to take effect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sounds straightforward, right? Let's look at what happens under the hood.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deriving the Data Model from Use Cases
&lt;/h2&gt;

&lt;p&gt;Before implementing, I take time to think about what the system needs to do and what data structures are required to support those use cases.&lt;/p&gt;

&lt;p&gt;The first thing we hear in the spec is "multiple tenants." A system that serves multiple companies and their users. That gives us at least two objects to start with: &lt;code&gt;tenants&lt;/code&gt; and &lt;code&gt;users&lt;/code&gt;.&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.amazonaws.com%2Fuploads%2Farticles%2Fvq0qlldxqb20juquyema.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.amazonaws.com%2Fuploads%2Farticles%2Fvq0qlldxqb20juquyema.png" alt="Tenants and users entity" width="800" height="238"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: I use the plural convention for table names throughout this post.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Defining the Relationship Between Tenants and Users
&lt;/h2&gt;

&lt;p&gt;Now we have the foundation, but the relationship between &lt;code&gt;tenants&lt;/code&gt; and &lt;code&gt;users&lt;/code&gt; is not yet defined. To define it, we need to find the right constraint. There are two cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Case 1:&lt;/strong&gt; One tenant can have many users. One user must use a unique email per tenant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Case 2:&lt;/strong&gt; One tenant can have many users. One user can use the same email across different tenants.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Consider this scenario: you are a contractor working for two different companies that both use this platform. If we go with Case 1, the best you can do is maintain two separate emails for two separate tenants on the same system. That is a real inconvenience.&lt;/p&gt;

&lt;p&gt;Notice I used the word &lt;strong&gt;person&lt;/strong&gt; instead of &lt;strong&gt;user&lt;/strong&gt; here. Technically, every tenant is independent. There is no good reason to force a person to use a different email just to become a user of another tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 2 is the preferred design.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tenant Identification at Login
&lt;/h2&gt;

&lt;p&gt;This raises a natural question: if a user can log in with the same email across multiple tenants, how do we know which tenant they belong to?&lt;/p&gt;

&lt;p&gt;The answer is that when a user logs in, we need to identify the tenant. There are a few approaches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ask the user to type a tenant code in the login form. Bad idea.&lt;/li&gt;
&lt;li&gt;Provide a typeahead field to help the user find their tenant code. Still a bad idea.&lt;/li&gt;
&lt;li&gt;Give each tenant a subdomain or sub-path, such as &lt;code&gt;tenant1.ourplatform.com&lt;/code&gt; or &lt;code&gt;ourplatform.com/tenant1&lt;/code&gt;. This is the widely accepted approach, especially the subdomain variant. The only tradeoff is that instead of a standard SSL certificate, you need a wildcard SSL certificate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Identity Table
&lt;/h2&gt;

&lt;p&gt;Another natural question follows: if users share an email across tenants, can they also share a password?&lt;/p&gt;

&lt;p&gt;No. Password policies can differ per tenant, so sharing passwords across tenants is not acceptable. This leads us to a third object: the &lt;code&gt;identities&lt;/code&gt; table. An identity represents a user's membership in a specific tenant, containing tenant's specific information.&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.amazonaws.com%2Fuploads%2Farticles%2Fhg4adsc2dxv9be75iejw.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.amazonaws.com%2Fuploads%2Farticles%2Fhg4adsc2dxv9be75iejw.png" alt="Identities entity" width="800" height="515"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The relationship is clear: one user can have many identities, and each identity belongs to exactly one tenant. With this simple three-table model - &lt;code&gt;tenants&lt;/code&gt;, &lt;code&gt;users&lt;/code&gt;, and &lt;code&gt;identities&lt;/code&gt; - we satisfy the core constraint cleanly: a person can use the same email across multiple independent tenants.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recognizing the Many-to-Many Relationship
&lt;/h2&gt;

&lt;p&gt;Look closely at the diagram. Both the N (of 1:N) ends from &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;tenants&lt;/code&gt; point to the &lt;code&gt;identities&lt;/code&gt; table. This is a sign that &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;tenants&lt;/code&gt; have an N:N relationship - a user can belong to multiple tenants, and a tenant can have multiple users. The &lt;code&gt;identities&lt;/code&gt; table is effectively the join table.&lt;/p&gt;

&lt;p&gt;With that insight, we can express the data model more precisely:&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.amazonaws.com%2Fuploads%2Farticles%2Fpxzdgtmgpv3lgjnh9r6b.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.amazonaws.com%2Fuploads%2Farticles%2Fpxzdgtmgpv3lgjnh9r6b.png" alt="Identities entity" width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;This post uses &lt;a href="https://schemity.com/" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt; as the &lt;a href="https://schemity.com/" rel="noopener noreferrer"&gt;ERD tool&lt;/a&gt; for all database diagrams.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;In Part 2, we will build on this foundation and design the roles and permissions model.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>erd</category>
      <category>database</category>
      <category>schema</category>
      <category>databasedesign</category>
    </item>
    <item>
      <title>Schemity vs dbdiagram.io: When Your ERD Tool Needs to Outlive the Meeting</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 02 Apr 2026 10:36:02 +0000</pubDate>
      <link>https://forem.com/tbson87/schemity-vs-dbdiagramio-when-your-erd-tool-needs-to-outlive-the-meeting-28m3</link>
      <guid>https://forem.com/tbson87/schemity-vs-dbdiagramio-when-your-erd-tool-needs-to-outlive-the-meeting-28m3</guid>
      <description>&lt;p&gt;There's a moment every software engineer knows.&lt;/p&gt;

&lt;p&gt;You're explaining a new feature to a teammate. The schema is in your head. You need something visual, fast, right now. You open a browser tab, type &lt;code&gt;dbdiagram.io&lt;/code&gt;, and in three minutes you have a diagram you can paste into Slack.&lt;/p&gt;

&lt;p&gt;The meeting happens. Everyone nods. The diagram link dies in the chat history. The actual schema lives in your database, unrelated to what you drew.&lt;/p&gt;

&lt;p&gt;That's not a failure of discipline. That's the ceiling of the tool.&lt;/p&gt;

&lt;p&gt;This article is for engineers who have hit that ceiling and are wondering what comes next.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;dbdiagram.io&lt;/strong&gt; is a fast, focused ERD tool built for quick sketching. Fast, free, browser-based, zero friction. If you need a diagram for a meeting and you'll never look at it again, nothing beats it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schemity&lt;/strong&gt; is what you use when the diagram needs to outlive the meeting. When your ERD is part of your codebase, not a link in Slack. When you want to go from sketch to production schema without switching tools.&lt;/p&gt;

&lt;p&gt;If that distinction doesn't matter to you yet, use dbdiagram.io. It's genuinely excellent at what it does.&lt;/p&gt;

&lt;p&gt;If it does matter, read on.&lt;/p&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;dbdiagram.io&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;Interface&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Code-based (DBML syntax)&lt;/td&gt;
&lt;td&gt;Visual drag-and-drop&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 only&lt;/td&gt;
&lt;td&gt;Native desktop (macOS, Windows, Linux)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Install size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None (web)&lt;/td&gt;
&lt;td&gt;9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Offline&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Full offline, air-gap ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Free tier&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10 public diagrams, no private, 1 viewer&lt;/td&gt;
&lt;td&gt;2-week full trial, all features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Privacy / diagram limits&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Upgrade to $9/month required&lt;/td&gt;
&lt;td&gt;2-week full trial, fully usable forever after&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Your data location&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Their servers&lt;/td&gt;
&lt;td&gt;Your machine, plain JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Git workflow&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Manual export&lt;/td&gt;
&lt;td&gt;Native: ERD is a JSON file in your repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Live DB sync&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Limited: export SQL scripts only, no direct sync&lt;/td&gt;
&lt;td&gt;Yes: reverse-engineer, sync, generate migrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Migration generation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes: exact SQL diff, useful for POC and personal projects&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;$9/month&lt;/td&gt;
&lt;td&gt;$129 one-time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Keyboard shortcuts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;Keyboard-first, every action has a shortcut&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multiple tabs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Browser tabs only, no isolated undo history&lt;/td&gt;
&lt;td&gt;Yes: native tabs with isolated undo history per diagram&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Constraints &amp;amp; indexes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Full: unique together, check constraints, indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;N:N relationships&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Auto-generated junction table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Relationship line routing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fixed, no control&lt;/td&gt;
&lt;td&gt;Custom waypoints, smart rerouting, full manual control&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Where dbdiagram.io Wins
&lt;/h2&gt;

&lt;p&gt;Let's be honest about this. dbdiagram.io does several things better than almost any alternative.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed from zero.&lt;/strong&gt; No download. No account required. Open browser, start typing, diagram appears in real time. For the specific use case of "I need an ERD right now," nothing is faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DBML syntax.&lt;/strong&gt; If you think in code, DBML is elegant. Define a table in four lines. Copy it from your ORM model. The keyboard never leaves the home row. For engineers who find drag-and-drop slower than typing, this is genuinely the right interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sharing is trivial.&lt;/strong&gt; One link. Anyone can view it. No account needed for viewers. For sharing with non-technical stakeholders or across teams, the link-based sharing model is hard to beat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free.&lt;/strong&gt; For individual use up to 10 diagrams, you pay nothing. For a lot of engineers, that's enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where dbdiagram.io Hits Its Ceiling
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The diagram is not your schema. It's a picture of your schema.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the fundamental problem. dbdiagram.io creates a visualization. It doesn't connect to your database. It doesn't track changes. It doesn't generate migrations. When your schema evolves (and it always does), your diagram falls behind. Within weeks, the link you shared is a historical artifact, not a current document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your data lives on their servers, not yours.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every diagram you create in dbdiagram.io is stored on Holistics' infrastructure. If they shut down, your diagrams disappear. If your company requires data locality (SOC 2, ISO 27001, regulated industries), a browser tool storing your schema on a third-party server is a compliance question you'd rather not answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The free tier is a public diagram trap.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The free tier gives you 10 diagrams, all public, all visible to anyone with the link, with no private option. The moment you need diagram number 11, or your first private diagram, the pricing jumps to $9/month. For most engineers working on real projects, "public diagrams only" is a non-starter from day one. Your schema is not a public document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No offline.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Working on a flight. Behind a corporate firewall. On a client site with restricted internet. dbdiagram.io simply doesn't work. For a tool that's supposed to help you think through your architecture, internet dependency is a real constraint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DBML has a learning curve for non-coders.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're the senior engineer who thinks in SQL, DBML feels natural. If you're onboarding a junior, or collaborating with a data analyst who doesn't write SQL daily, the code-first interface creates friction. The visual feedback is good, but the input method is a barrier.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Failure Modes Schemity Was Built to Solve
&lt;/h2&gt;

&lt;p&gt;Schemity wasn't built to compete with dbdiagram.io on its own terms. It was built to solve three specific failure modes that exist across the entire ERD tool market.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure Mode 1: The tool defeats you before you start
&lt;/h3&gt;

&lt;p&gt;Visual Paradigm is a serious modeling tool. It does have a Community edition that is free. But finding it is its own obstacle. The download page is cluttered with editions, pricing tiers, and license options that make it genuinely unclear which version to download. Engineers who just want to start modeling end up reading licensing FAQs instead of drawing tables.&lt;/p&gt;

&lt;p&gt;Once you do find the right download, the installer takes over. JVM dependency, license wizard, 200+ MB download. Motivated engineers have abandoned it before creating a single entity. The tool defeated them before they opened it.&lt;/p&gt;

&lt;p&gt;Schemity is 9 MB. It starts in seconds. There is no IT ticket, no JRE installation, no license wizard, no edition confusion. One download, one version, running immediately. The activation energy is low enough that you open it before the meeting, not after.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure Mode 2: Fast sketching and serious design are mutually exclusive
&lt;/h3&gt;

&lt;p&gt;The market has forced a false choice: either use a basic tool that's fast (dbdiagram.io) or use an enterprise tool that's serious (Visual Paradigm, erwin). There is almost nothing in between.&lt;/p&gt;

&lt;p&gt;Schemity is drag-and-drop fast. Keyboard shortcuts make it faster still once you're comfortable. But the same tool that handles a five-minute sketch also handles a production schema with 100+ tables, composite unique constraints, check constraints, indexes, and SSH-connected live database sync. It can also generate SQL migrations directly from your ERD, useful for personal projects and fast POCs where you don't need a full migration framework in the way. For production team workflows, your existing migration tooling stays in charge. Schemity sits alongside it, not in front of it.&lt;/p&gt;

&lt;p&gt;You don't graduate to another tool. You just use more of the same tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure Mode 3: Trial expiry holds your work hostage
&lt;/h3&gt;

&lt;p&gt;This is the fear nobody talks about in ERD tool marketing. You spend two hours in a trial. The trial expires. Your diagrams are either gone or locked behind a paywall. And if you were on dbdiagram.io's free tier the whole time, your schema was public to anyone with the link. Serious, sensitive work sitting on a public URL without you realizing it.&lt;/p&gt;

&lt;p&gt;Schemity's answer to this is architectural, not a policy. Your ERDs are plain JSON files in &lt;code&gt;~/schemity/&lt;/code&gt; on your own machine. When the trial expires, Schemity stops receiving updates. You get a single friendly reminder when you open the app, once per session, not a popup that blocks your work or repeats every five minutes. Everything else works exactly as before. Your files are yours. They always were.&lt;/p&gt;

&lt;p&gt;If Schemity shuts down as a business tomorrow, your diagrams open perfectly on Monday.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Git Workflow Nobody Else Has
&lt;/h2&gt;

&lt;p&gt;This is the feature that changes how a team works with database design.&lt;/p&gt;

&lt;p&gt;In dbdiagram.io, your ERD lives at a URL. You share the link. When the schema changes, someone updates the diagram manually, if they remember. The diagram and the codebase drift apart over time. Within months, the link you shared in Slack is a historical artifact, not a current document.&lt;/p&gt;

&lt;p&gt;Schemity takes a fundamentally different approach, one borrowed from how mature engineering teams already handle infrastructure.&lt;/p&gt;

&lt;p&gt;Nobody commits Terraform files inside an application service repo. Infrastructure is a shared concern, it spans services, it has its own change cadence, it deserves its own review. So it lives in its own repo.&lt;/p&gt;

&lt;p&gt;Your database schema deserves the same treatment.&lt;/p&gt;

&lt;p&gt;The pattern Schemity enables is an ERD repo: a single, dedicated repository that holds the ERDs for every service in your company.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;company-erd-repo/
  ├── payment-service/
  ├── user-service/
  ├── notification-service/
  └── legacy-crm/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each folder is a Schemity workspace containing plain JSON files. The entire repo lives as &lt;code&gt;~/schemity/&lt;/code&gt; on every engineer's machine. Schemity opens it as a central hub.&lt;/p&gt;

&lt;p&gt;Schemity does not stand in your workflow. It does not replace your migration tool. Your schema changes still flow through whatever your team already uses: Django, Rails, Flyway, Atlas, anything. Schemity sits alongside that workflow, not in front of it.&lt;/p&gt;

&lt;p&gt;Here is what it looks like in practice:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the engineer making the change:&lt;/strong&gt; Clone &lt;code&gt;company-erd-repo&lt;/code&gt; once into &lt;code&gt;~/schemity/&lt;/code&gt;. Run your migration as normal. Once it is applied to your local database, open Schemity, connect to it, and let it reflect the current state. Arrange the entities, update the relationships, add legends to give the diagram business meaning. Commit the updated JSON and open a PR against the ERD repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the engineer reviewing the change:&lt;/strong&gt; Pull the branch. Open Schemity. The diff is right there, a JSON file change that maps directly to a visual diagram. No migration file archaeology. No mental reconstruction of what the schema looked like before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the architect reviewing multiple services:&lt;/strong&gt; One repo. One clone. Every service's schema in one place. No hopping between repos hoping someone committed a diagram.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the new engineer onboarding:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@github.com:company/erd-repo.git ~/schemity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. Every service. Every relationship. Every constraint. The full picture of the company's data model, version-controlled and up to date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this unlocks that dbdiagram.io never can:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema changes get reviewed like code.&lt;/strong&gt; When a schema change requires a PR against the ERD repo, the change gets reviewed before it is applied, not after. The history of every schema decision lives in Git: who changed it, when, and why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The database is always the source of truth.&lt;/strong&gt; Real teams miss steps. An engineer ships a migration in a busy sprint and forgets to update the ERD. It happens. With dbdiagram.io, that diagram is now wrong and nobody knows it. With Schemity, the next time anyone opens that ERD and connects to the database, Schemity reflects the current state automatically. The new tables are there. The new relationships are there. The new columns are there. The only thing missing is the visual arrangement: where the entities sit on the canvas and how the lines are routed. Drag a few boxes, tidy the lines, commit. No data lost. Nothing out of date. The diagram catches up in minutes, not sprint cycles.&lt;/p&gt;

&lt;p&gt;This is separation of concerns applied to the full engineering stack. Source code in service repos. Infrastructure in the infra repo. Schema in the ERD repo.&lt;/p&gt;

&lt;p&gt;It is not a feature dbdiagram.io can add. It would require rethinking what an ERD tool is for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pricing: One-Time vs. Monthly
&lt;/h2&gt;

&lt;p&gt;dbdiagram.io: &lt;strong&gt;$9/month&lt;/strong&gt; ($108/year) once you exceed the free tier.&lt;/p&gt;

&lt;p&gt;Schemity: &lt;strong&gt;$129 one-time&lt;/strong&gt; with $69/year renewal for updates (optional, the tool works forever without renewing).&lt;/p&gt;

&lt;p&gt;At 14 months, Schemity costs the same as dbdiagram.io's subscription. Every month after that, Schemity is free. If you use it for three years (and engineers tend to stick with tools that work), Schemity costs $129 + $138 = $267 total. dbdiagram.io costs $324 for the same period, and you still don't own it.&lt;/p&gt;

&lt;p&gt;The more important pricing difference is philosophical. Schemity's one-time model is a statement: the tool is valuable enough that you'll keep using it without being forced to. You don't stay because canceling is painful. You stay because the tool earns it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who Should Use dbdiagram.io
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You need a diagram in the next five minutes for a meeting&lt;/li&gt;
&lt;li&gt;You'll never look at this diagram again after today&lt;/li&gt;
&lt;li&gt;You're communicating a schema idea, not building one&lt;/li&gt;
&lt;li&gt;You think in DBML and the code-first interface is faster for you&lt;/li&gt;
&lt;li&gt;The diagram doesn't need to live anywhere except a Slack link&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;dbdiagram.io is excellent for this use case. Use it without guilt.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;You need the diagram to still be accurate next month&lt;/li&gt;
&lt;li&gt;Your ERD belongs in your repository, not in someone else's cloud&lt;/li&gt;
&lt;li&gt;You work offline, behind a VPN, or in a restricted environment&lt;/li&gt;
&lt;li&gt;You're graduating from a basic tool without wanting a tool that feels like an enterprise installation ceremony&lt;/li&gt;
&lt;li&gt;You want to generate migrations directly from your design for a personal project or fast POC, without a full migration framework in the way&lt;/li&gt;
&lt;li&gt;You're a senior engineer recommending a tool to a junior who shouldn't be defeated by the installer&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;dbdiagram.io is a sketch pad. It's a very good sketch pad.&lt;/p&gt;

&lt;p&gt;Schemity is a design tool that happens to be as fast as a sketch pad.&lt;/p&gt;

&lt;p&gt;The question is not which tool is better in absolute terms. The question is whether your ERD is a communication artifact that lives in a meeting, or a design artifact that lives in your codebase.&lt;/p&gt;

&lt;p&gt;If it's the latter, Schemity was built for you.&lt;/p&gt;

&lt;p&gt;If Schemity sounds like the right fit, you can grab it at &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;schemity.com&lt;/a&gt;, 9 MB, 2-week full trial, no account required.&lt;/p&gt;

</description>
      <category>erd</category>
      <category>database</category>
      <category>schema</category>
      <category>databasedesign</category>
    </item>
  </channel>
</rss>
