<?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>SQL Server Multiple Cascade Paths (Error 1785): Which Foreign Key Loses ON DELETE CASCADE, and What Replaces It</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 12 Nov 2026 07:48:30 +0000</pubDate>
      <link>https://dev.to/tbson87/sql-server-multiple-cascade-paths-error-1785-which-foreign-key-loses-on-delete-cascade-and-what-55an</link>
      <guid>https://dev.to/tbson87/sql-server-multiple-cascade-paths-error-1785-which-foreign-key-loses-on-delete-cascade-and-what-55an</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; SQL Server refuses a foreign key when its &lt;code&gt;CASCADE&lt;/code&gt;, &lt;code&gt;SET NULL&lt;/code&gt; or &lt;code&gt;SET DEFAULT&lt;/code&gt; would let one delete or update reach the same table by two paths, and it decides that from the declared actions when the key is created, not from the rows. One key has to become &lt;code&gt;NO ACTION&lt;/code&gt;, which makes some deletes fail with error 547 until a stored procedure or an &lt;code&gt;INSTEAD OF DELETE&lt;/code&gt; trigger on the parent removes the child rows first. An &lt;code&gt;AFTER DELETE&lt;/code&gt; trigger cannot do it, and &lt;code&gt;SET NULL&lt;/code&gt; on the second key is refused like &lt;code&gt;CASCADE&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Error 1785 means two of your foreign keys would both act on the same table when one parent row is deleted or updated, and SQL Server will not create the second one, even on empty tables. The fix has two parts. One of the keys has to become &lt;code&gt;NO ACTION&lt;/code&gt;, and then something else has to remove the rows that key used to remove, because a &lt;code&gt;NO ACTION&lt;/code&gt; key turns some deletes into error 547. Keep the cascade on the key that says who owns the row, and replace the other one with a stored procedure or an &lt;code&gt;INSTEAD OF DELETE&lt;/code&gt; trigger on the parent table.&lt;/p&gt;

&lt;p&gt;Everything below ran on SQL Server 2022 (16.0.4265.3, CU26) in a throwaway Docker container, and again on SQL Server 2025 (17.0.5035.5) with the same results. The schema is three tables: &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;posts&lt;/code&gt; with a &lt;code&gt;user_id&lt;/code&gt;, and &lt;code&gt;comments&lt;/code&gt; with a &lt;code&gt;post_id&lt;/code&gt; and a &lt;code&gt;user_id&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does SQL Server say a foreign key may cause cycles or multiple cascade paths?
&lt;/h2&gt;

&lt;p&gt;Because deleting one row would reach the same table by two routes, and SQL Server allows only one route to each table per delete or update.&lt;/p&gt;

&lt;p&gt;A comment belongs to a post and was written by a user, and a post was written by a user. Give all three keys &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; and a deleted user reaches &lt;code&gt;comments&lt;/code&gt; twice: directly, and through &lt;code&gt;posts&lt;/code&gt;.&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;INT&lt;/span&gt; &lt;span class="no"&gt;IDENTITY&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="n"&gt;name&lt;/span&gt; &lt;span class="no"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;INT&lt;/span&gt; &lt;span class="no"&gt;IDENTITY&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="n"&gt;user_id&lt;/span&gt; &lt;span class="kt"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="no"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;fk_posts_user&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="n"&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="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;comments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;INT&lt;/span&gt; &lt;span class="no"&gt;IDENTITY&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="n"&gt;post_id&lt;/span&gt; &lt;span class="kt"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="kt"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="no"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;fk_comments_post&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="n"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;fk_comments_user&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="n"&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="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Msg 1785, Level 16, State 1&lt;/span&gt;
&lt;span class="c1"&gt;-- Introducing FOREIGN KEY constraint 'fk_comments_user' on table 'comments'&lt;/span&gt;
&lt;span class="c1"&gt;-- may cause cycles or multiple cascade paths. Specify ON DELETE NO ACTION or&lt;/span&gt;
&lt;span class="c1"&gt;-- ON UPDATE NO ACTION, or modify other FOREIGN KEY constraints.&lt;/span&gt;
&lt;span class="c1"&gt;-- Msg 1750: Could not create constraint or index. See previous errors.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;comments&lt;/code&gt; table is not created at all. Microsoft's &lt;a href="https://learn.microsoft.com/en-us/sql/relational-databases/errors-events/mssqlserver-1785-database-engine-error" rel="noopener noreferrer"&gt;page for error 1785&lt;/a&gt; gives the rule: "a table cannot appear more than one time in a list of all the cascading referential actions that are started by either a &lt;code&gt;DELETE&lt;/code&gt; or an &lt;code&gt;UPDATE&lt;/code&gt; statement." The check reads the declared actions only. There were no rows in any table when it failed.&lt;/p&gt;

&lt;p&gt;The key the message names is the one that arrived second, not the one that is wrong. With both keys in one &lt;code&gt;CREATE TABLE&lt;/code&gt; it named &lt;code&gt;fk_comments_user&lt;/code&gt;. When I created &lt;code&gt;comments&lt;/code&gt; with only the user key and added the post key afterwards with &lt;code&gt;ALTER TABLE&lt;/code&gt;, the same error named &lt;code&gt;fk_comments_post&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;PostgreSQL 18.3 and MySQL 8.4.11 created the same three keys without complaint, and deleting one user there removed that user's post and every comment on either path. So this is a rule of SQL Server, not of SQL, and it tends to arrive from an ORM: Entity Framework Core configures required relationships to cascade by default, and its documentation on &lt;a href="https://learn.microsoft.com/en-us/ef/core/saving/cascade-delete#database-cascade-limitations" rel="noopener noreferrer"&gt;database cascade limitations&lt;/a&gt; shows this exact exception on a blog, post and person model.&lt;/p&gt;

&lt;p&gt;The diamond is one of three shapes SQL Server refused, along with two keys from one parent and a self-reference:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;Keys&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct and indirect path into one table&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;users&lt;/code&gt; to &lt;code&gt;posts&lt;/code&gt; to &lt;code&gt;comments&lt;/code&gt;, plus &lt;code&gt;users&lt;/code&gt; to &lt;code&gt;comments&lt;/code&gt;, all &lt;code&gt;CASCADE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Msg 1785&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The same, with &lt;code&gt;SET NULL&lt;/code&gt; on &lt;code&gt;comments.user_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CASCADE&lt;/code&gt;, &lt;code&gt;CASCADE&lt;/code&gt;, &lt;code&gt;SET NULL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Msg 1785&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The same, with &lt;code&gt;SET NULL&lt;/code&gt; on &lt;code&gt;comments.post_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CASCADE&lt;/code&gt;, &lt;code&gt;SET NULL&lt;/code&gt;, &lt;code&gt;CASCADE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Msg 1785&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two keys from one parent&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;messages.sender_id&lt;/code&gt; and &lt;code&gt;messages.recipient_id&lt;/code&gt; to &lt;code&gt;users&lt;/code&gt;, both &lt;code&gt;CASCADE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Msg 1785&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two keys from one parent&lt;/td&gt;
&lt;td&gt;one &lt;code&gt;CASCADE&lt;/code&gt;, one &lt;code&gt;SET NULL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Msg 1785&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two keys from one parent&lt;/td&gt;
&lt;td&gt;one &lt;code&gt;CASCADE&lt;/code&gt;, one &lt;code&gt;NO ACTION&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Created&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-reference&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;categories.parent_id&lt;/code&gt; to &lt;code&gt;categories&lt;/code&gt;, &lt;code&gt;CASCADE&lt;/code&gt; or &lt;code&gt;SET NULL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Msg 1785&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two &lt;code&gt;ON UPDATE CASCADE&lt;/code&gt; keys from one parent&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;transfers.from_id&lt;/code&gt; and &lt;code&gt;transfers.to_id&lt;/code&gt; to &lt;code&gt;users&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Msg 1785&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;SET NULL&lt;/code&gt; does not get around it, and &lt;code&gt;ON UPDATE&lt;/code&gt; is checked the same way as &lt;code&gt;ON DELETE&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which foreign key should be NO ACTION?
&lt;/h2&gt;

&lt;p&gt;The key that records a reference rather than ownership: keep &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; from &lt;code&gt;posts&lt;/code&gt; to &lt;code&gt;comments&lt;/code&gt;, because a comment cannot exist without its post, and make &lt;code&gt;comments.user_id&lt;/code&gt; the &lt;code&gt;NO ACTION&lt;/code&gt; key.&lt;/p&gt;

&lt;p&gt;Either choice makes SQL Server accept the schema, and either choice makes some deletes fail. (A third, &lt;code&gt;NO ACTION&lt;/code&gt; on &lt;code&gt;posts.user_id&lt;/code&gt;, is accepted too, but then deleting any user who has a post fails with Msg 547 on &lt;code&gt;fk_posts_user&lt;/code&gt;, so it is not a candidate.) I loaded three users, one post each, and four comments: Ana on her own post, Ana on Ben's post, Ben on Ana's post, and Cy on his own post. Then I tried both versions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Delete&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;NO ACTION&lt;/code&gt; on &lt;code&gt;comments.user_id&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;NO ACTION&lt;/code&gt; on &lt;code&gt;comments.post_id&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A post that has comments&lt;/td&gt;
&lt;td&gt;Works, the comments go with it&lt;/td&gt;
&lt;td&gt;Fails, Msg 547 on &lt;code&gt;fk_comments_post&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cy, who only commented on his own post&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ana, who commented on Ben's post and has Ben's comment on hers&lt;/td&gt;
&lt;td&gt;Fails, Msg 547 on &lt;code&gt;fk_comments_user&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Fails, Msg 547 on &lt;code&gt;fk_comments_post&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The failure reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Msg 547, Level 16, State 1
The DELETE statement conflicted with the REFERENCE constraint "fk_comments_user".
The conflict occurred in database "blog", table "dbo.comments", column 'user_id'.
The statement has been terminated.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cy's delete works in both versions because SQL Server applies every cascade before it checks a &lt;code&gt;NO ACTION&lt;/code&gt; key, so by the time it looks, his one comment is already gone with his post. Ana's fails because a row survives the cascades and still points at something being deleted.&lt;/p&gt;

&lt;p&gt;That makes the first column the better schema. With &lt;code&gt;NO ACTION&lt;/code&gt; on &lt;code&gt;comments.post_id&lt;/code&gt;, deleting any post that has a comment fails, which breaks the most ordinary delete in the application. With &lt;code&gt;NO ACTION&lt;/code&gt; on &lt;code&gt;comments.user_id&lt;/code&gt;, posts delete normally and only one operation needs help: deleting a user who has commented on someone else's post. The schema is accepted, and the delete that used to be one statement is now your job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What replaces the cascade you removed?
&lt;/h2&gt;

&lt;p&gt;A stored procedure, or an &lt;code&gt;INSTEAD OF DELETE&lt;/code&gt; trigger on the parent table, that removes the child rows before the parent row; an &lt;code&gt;AFTER DELETE&lt;/code&gt; trigger cannot do it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Replacement&lt;/th&gt;
&lt;th&gt;Result for deleting Ana&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;AFTER DELETE&lt;/code&gt; trigger on &lt;code&gt;users&lt;/code&gt; that deletes her comments&lt;/td&gt;
&lt;td&gt;Still Msg 547. The key is checked before the trigger fires, so it never runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;INSTEAD OF DELETE&lt;/code&gt; trigger on &lt;code&gt;users&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Works: Ana, her post and every comment on either path are gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stored procedure that deletes her comments, then her, in one transaction&lt;/td&gt;
&lt;td&gt;Works, same rows removed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ON DELETE SET NULL&lt;/code&gt; on &lt;code&gt;comments.user_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not available, Msg 1785&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trigger that worked:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;trg_users_delete&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="no"&gt;INSTEAD&lt;/span&gt; &lt;span class="no"&gt;OF&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="no"&gt;NOCOUNT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;comments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads the &lt;code&gt;deleted&lt;/code&gt; table as a set, so &lt;code&gt;DELETE FROM users WHERE id IN (2, 3)&lt;/code&gt; handled both users in one statement. The second &lt;code&gt;DELETE&lt;/code&gt; inside it does not fire the trigger again; it runs the real delete, and &lt;code&gt;fk_posts_user&lt;/code&gt; and &lt;code&gt;fk_comments_post&lt;/code&gt; cascade as declared.&lt;/p&gt;

&lt;p&gt;If a comment should outlive its author, the trigger is also where the null goes, since SQL Server refuses &lt;code&gt;SET NULL&lt;/code&gt; on that key. Make &lt;code&gt;comments.user_id&lt;/code&gt; nullable and swap the first statement for &lt;code&gt;UPDATE comments SET user_id = NULL WHERE user_id IN (SELECT id FROM deleted)&lt;/code&gt;. Deleting Ana then left her comment on Ben's post in place with no author, and removed her post and the comments on it.&lt;/p&gt;

&lt;p&gt;The trigger has three limits, all measured:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only fits a table that has no cascading foreign key of its own. &lt;code&gt;CREATE TRIGGER ... ON posts INSTEAD OF DELETE&lt;/code&gt; failed with Msg 2113, "Cannot create INSTEAD OF DELETE or INSTEAD OF UPDATE TRIGGER 'trg_posts_delete' on table 'posts'. This is because the table has a FOREIGN KEY with cascading DELETE or UPDATE.", because &lt;code&gt;posts.user_id&lt;/code&gt; cascades from &lt;code&gt;users&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It closes a door on the table that has it. With the trigger on &lt;code&gt;users&lt;/code&gt;, adding &lt;code&gt;users.org_id&lt;/code&gt; with &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; to an &lt;code&gt;orgs&lt;/code&gt; table failed with Msg 1787. The same key with &lt;code&gt;ON DELETE SET NULL&lt;/code&gt; was accepted.&lt;/li&gt;
&lt;li&gt;It breaks a &lt;code&gt;DELETE&lt;/code&gt; that has an &lt;code&gt;OUTPUT&lt;/code&gt; clause. &lt;code&gt;DELETE FROM users OUTPUT 1 WHERE id = 3&lt;/code&gt; failed with Msg 334, "The target table 'users' of the DML statement cannot have any enabled triggers if the statement contains an OUTPUT clause without INTO clause." EF Core 7 and later save changes with &lt;code&gt;OUTPUT&lt;/code&gt; by default, so the entity has to be configured with &lt;code&gt;HasTrigger&lt;/code&gt; before EF Core can write to that table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A stored procedure has none of these limits, and it is the same two statements in a transaction:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;PROCEDURE&lt;/span&gt; &lt;span class="n"&gt;delete_user&lt;/span&gt; &lt;span class="nd"&gt;@user_id&lt;/span&gt; &lt;span class="kt"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="no"&gt;NOCOUNT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="no"&gt;XACT_ABORT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="k"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;comments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;@user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;@user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its cost is that it only protects callers who use it. A plain &lt;code&gt;DELETE FROM users&lt;/code&gt; from another service, a script or an ORM still fails with Msg 547, which at least fails loudly rather than leaving orphans. Doing the same two statements from application code is equivalent, and it is what EF Core's &lt;code&gt;ClientCascade&lt;/code&gt; option does for rows it has loaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you delete a tree when a self-reference cannot cascade?
&lt;/h2&gt;

&lt;p&gt;Leave the self-referencing key at &lt;code&gt;NO ACTION&lt;/code&gt; and delete the whole subtree in one statement with a recursive common table expression.&lt;/p&gt;

&lt;p&gt;SQL Server refused &lt;code&gt;categories.parent_id&lt;/code&gt; with &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; and with &lt;code&gt;ON DELETE SET NULL&lt;/code&gt;. With the key at &lt;code&gt;NO ACTION&lt;/code&gt;, deleting a category that has children fails with Msg 547, "The DELETE statement conflicted with the SAME TABLE REFERENCE constraint". Collecting the subtree first works, because the key is checked against the rows the whole statement leaves behind, and parent and children go together:&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;WITH&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;OPTION&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;MAXRECURSION&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On four rows, a chain of 1, 2 and 3 plus an unrelated row 4, that statement removed the chain and left row 4. The &lt;code&gt;OPTION (MAXRECURSION 0)&lt;/code&gt; matters on a deep tree: SQL Server stops a recursive query after 100 levels by default, and without the option a chain of 150 categories failed with Msg 530, "The maximum recursion 100 has been exhausted before statement completion", and deleted nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity flags a second cascade path before SQL Server does
&lt;/h2&gt;

&lt;p&gt;Schemity's lint reports the foreign key that adds the second path while it is still a line on the diagram, and names the key it collides with.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. On a SQL Server diagram, whether it is &lt;a href="https://schemity.com/doc/connect-sql-server/" rel="noopener noreferrer"&gt;connected to a database&lt;/a&gt; or a design with no connection yet, the &lt;code&gt;ON DELETE&lt;/code&gt; and &lt;code&gt;ON UPDATE&lt;/code&gt; actions are set in the &lt;a href="https://schemity.com/doc/relationships/" rel="noopener noreferrer"&gt;relationship dialog&lt;/a&gt;, and nothing stops you from choosing &lt;code&gt;CASCADE&lt;/code&gt; on both of a table's keys. On an unconnected design there is no server to object, so the first place the problem can show is &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;schema lint&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feyib28jx2fzu1qn1rtwm.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feyib28jx2fzu1qn1rtwm.webp" alt="The users, posts and comments tables read from a SQL Server 2022 database in Schemity: users to posts and posts to comments drawn with a bold crow's foot because they cascade, and a second line from users straight to comments.user_id, which is NO ACTION" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Changing that last key to &lt;code&gt;CASCADE&lt;/code&gt; is one dropdown in the dialog, and Schemity accepts it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5xg2mfwwrj5llr9svt3y.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5xg2mfwwrj5llr9svt3y.webp" alt="Schemity's Update relation dialog for users.id to comments.user_id, constraint fk_comments_user, Zero or Many, with On delete set to CASCADE and On update left at NO ACTION" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rule is &lt;code&gt;sqlserver-multiple-cascade-paths&lt;/code&gt;, in the "Fails at runtime" group, and it runs only on SQL Server diagrams. For the three tables above with all keys at &lt;code&gt;CASCADE&lt;/code&gt;, the finding sits on &lt;code&gt;comments.user_id&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;SQL Server refuses this foreign key: its ON DELETE action makes a second path between the same tables (Msg 1785).&lt;/p&gt;

&lt;p&gt;ON DELETE in users reaches comments through fk_comments_post and through this key. One of the two has to be NO ACTION.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn16qj3sr18ve3iyrgmmp.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn16qj3sr18ve3iyrgmmp.webp" alt="Schemity's Lint drawer after comments.user_id is set to ON DELETE CASCADE on SQL Server 2022: under Fails at runtime, the finding on fk_comments_user says SQL Server refuses this foreign key (Msg 1785), and the canvas marks the comments.user_id row in red with the relation from users drawn dotted" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On a connected diagram the finding also follows the change into Preview changes, next to &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;the planned migration&lt;/a&gt; it would run. The preview draws only &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;comments&lt;/code&gt;, the two tables the change touches, but lint reads the whole schema, so the path through &lt;code&gt;posts&lt;/code&gt; is still counted:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvmy2ya2aouyfbgtnm1s1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvmy2ya2aouyfbgtnm1s1.webp" alt="Schemity's change preview for the edited key on SQL Server 2022: the planned migration drops fk_comments_user and adds it again with ON DELETE CASCADE, Planned changes lists the foreign key on comments (user_id) as changed, and Lint: 4 includes comments.user_id, SQL Server refuses this foreign key (Msg 1785)" width="800" height="551"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Statement 4 in that migration is the one SQL Server would answer with Msg 1785. Schemity runs a SQL Server migration in one transaction and rolls it back on the first error, so the &lt;code&gt;DROP CONSTRAINT&lt;/code&gt; before it would be undone and the old &lt;code&gt;NO ACTION&lt;/code&gt; key would stay in place.&lt;/p&gt;

&lt;p&gt;It covers the same shapes as the table earlier: a direct and an indirect path, two acting keys from one parent, and a self-reference, where the detail says that the rows below have to be handled by the application or a trigger. It checks deletes and updates separately, as SQL Server does, and it counts &lt;code&gt;SET NULL&lt;/code&gt; and &lt;code&gt;SET DEFAULT&lt;/code&gt; as acting keys, so switching the second key to &lt;code&gt;SET NULL&lt;/code&gt; keeps the finding. It also follows one subtlety I confirmed on the server: an &lt;code&gt;ON DELETE SET NULL&lt;/code&gt; in the middle of a chain ends that path, because the row it changes is not deleted (an &lt;code&gt;ON UPDATE&lt;/code&gt; chain has no such exit). &lt;code&gt;users&lt;/code&gt; to &lt;code&gt;members&lt;/code&gt; with &lt;code&gt;CASCADE&lt;/code&gt;, &lt;code&gt;members&lt;/code&gt; to &lt;code&gt;tasks&lt;/code&gt; with &lt;code&gt;SET NULL&lt;/code&gt;, then &lt;code&gt;tasks&lt;/code&gt; to &lt;code&gt;notes&lt;/code&gt; with &lt;code&gt;CASCADE&lt;/code&gt; and &lt;code&gt;users&lt;/code&gt; to &lt;code&gt;notes&lt;/code&gt; with &lt;code&gt;SET NULL&lt;/code&gt; was created without error, and lint stays silent on it.&lt;/p&gt;

&lt;p&gt;What lint cannot tell you is which key to change, because that depends on which table owns the row. That decision is the one in the second section, and it is easier to make on an ERD, where both paths from &lt;code&gt;users&lt;/code&gt; to &lt;code&gt;comments&lt;/code&gt; are visible as lines, than in a migration file where the two keys are forty lines apart. When you design the next one, set the reference key to &lt;code&gt;NO ACTION&lt;/code&gt; in the dialog, and the finding clears before any SQL is written.&lt;/p&gt;

&lt;p&gt;Two related posts pick up from here: &lt;a href="https://schemity.com/blog/postgres-on-delete-set-null-vs-cascade/" rel="noopener noreferrer"&gt;ON DELETE SET NULL vs CASCADE in PostgreSQL&lt;/a&gt; covers choosing between the two actions where both are allowed, and &lt;a href="https://schemity.com/blog/circular-foreign-keys-first-row-cannot-be-inserted/" rel="noopener noreferrer"&gt;circular foreign keys&lt;/a&gt; covers the insert side of a cycle, which SQL Server permits as long as the actions do not go round it.&lt;/p&gt;

</description>
      <category>sqlserver</category>
      <category>database</category>
      <category>sql</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>MySQL ENUM vs CHECK Constraint: Which Value List Changes Copy the Table</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Wed, 11 Nov 2026 04:44:04 +0000</pubDate>
      <link>https://dev.to/tbson87/mysql-enum-vs-check-constraint-which-value-list-changes-copy-the-table-4jh3</link>
      <guid>https://dev.to/tbson87/mysql-enum-vs-check-constraint-which-value-list-changes-copy-the-table-4jh3</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; On MySQL 8.4, appending a value to the end of an &lt;code&gt;ENUM&lt;/code&gt; of up to 255 values is a metadata change that takes no time, while inserting one in the middle, reordering, or removing one copies the whole table and holds back writes. Removing a value that rows still hold fails with error 1265. An enforced &lt;code&gt;CHECK&lt;/code&gt; on a &lt;code&gt;VARCHAR&lt;/code&gt; copies the table for every change to its list, appends included, and a lookup table needs no &lt;code&gt;ALTER TABLE&lt;/code&gt; at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On MySQL 8.4, an &lt;code&gt;ENUM&lt;/code&gt; is the cheapest value list to extend and among the most expensive to edit any other way. Appending a value to the end of the list changes only the table definition and finished in 0.00 seconds on a million rows. Inserting a value in the middle, reordering the list or removing a value copies the whole table and holds back writes while it does, and removing a value that rows still hold fails outright. A &lt;code&gt;CHECK (status IN (...))&lt;/code&gt; on a &lt;code&gt;VARCHAR&lt;/code&gt; copies the table for every change to its list, appends included. So whether &lt;code&gt;ENUM&lt;/code&gt; or &lt;code&gt;CHECK&lt;/code&gt; is the better choice depends on how the list will change, not on how it looks today.&lt;/p&gt;

&lt;p&gt;Everything below ran on MySQL 8.4.11 in a throwaway Docker container on a laptop. The table is &lt;code&gt;tasks&lt;/code&gt;, with 1,000,000 rows and a &lt;code&gt;status ENUM('open','done','blocked') NOT NULL DEFAULT 'open'&lt;/code&gt; column. 400,000 rows hold &lt;code&gt;open&lt;/code&gt;, 599,964 hold &lt;code&gt;done&lt;/code&gt; and 36 hold &lt;code&gt;blocked&lt;/code&gt;. Each change ran on a fresh copy of the table, and the timings are there to compare kinds of change, not to predict your server.&lt;/p&gt;

&lt;h2&gt;
  
  
  MySQL ENUM vs CHECK constraint: which is cheaper to change?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;ENUM&lt;/code&gt; is cheaper only for an append within its storage width (and a change of letter case): every other &lt;code&gt;ENUM&lt;/code&gt; edit, and every edit to an enforced &lt;code&gt;CHECK&lt;/code&gt; list, copies the table.&lt;/p&gt;

&lt;p&gt;MySQL stores an &lt;code&gt;ENUM&lt;/code&gt; value as a number, the position of the value in the list, and keeps the strings in the table definition. Appending a string leaves every stored number pointing at the same value, so there is nothing to rewrite. Anything that moves a value to a different position, or takes one away, changes what the stored numbers mean, so MySQL rebuilds the table to write them again. The &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html" rel="noopener noreferrer"&gt;online DDL reference&lt;/a&gt; says it in one line: "Adding members in the middle of the list causes renumbering of existing members, which requires a table copy."&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change to &lt;code&gt;status&lt;/code&gt; on 1,000,000 rows&lt;/th&gt;
&lt;th&gt;What MySQL does&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Append &lt;code&gt;'archived'&lt;/code&gt; at the end&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INSTANT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.00 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert &lt;code&gt;'review'&lt;/code&gt; between &lt;code&gt;'open'&lt;/code&gt; and &lt;code&gt;'done'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Copies the table (&lt;code&gt;INSTANT&lt;/code&gt; and &lt;code&gt;INPLACE&lt;/code&gt; refused, 1846)&lt;/td&gt;
&lt;td&gt;1.58 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reorder to &lt;code&gt;('done','open','blocked')&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Copies the table&lt;/td&gt;
&lt;td&gt;1.50 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove &lt;code&gt;'archived'&lt;/code&gt;, which no row holds&lt;/td&gt;
&lt;td&gt;Copies the table&lt;/td&gt;
&lt;td&gt;1.53 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove &lt;code&gt;'blocked'&lt;/code&gt;, which 36 rows hold&lt;/td&gt;
&lt;td&gt;Fails, &lt;code&gt;ERROR 1265 (01000): Data truncated for column 'status' at row 2&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change the case of &lt;code&gt;'done'&lt;/code&gt; to &lt;code&gt;'Done'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;INSTANT&lt;/code&gt;, and every row now reads &lt;code&gt;Done&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.00 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Append a 256th value to a 255-value &lt;code&gt;ENUM&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Copies the table&lt;/td&gt;
&lt;td&gt;1.46 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Append a 9th member to an 8-member &lt;code&gt;SET&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Copies the table&lt;/td&gt;
&lt;td&gt;1.39 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Append a 10th member to a 9-member &lt;code&gt;SET&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INSTANT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.00 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add &lt;code&gt;'archived'&lt;/code&gt; to &lt;code&gt;CHECK (status IN (...))&lt;/code&gt; on a &lt;code&gt;VARCHAR(20)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Copies the table (&lt;code&gt;INSTANT&lt;/code&gt; and &lt;code&gt;INPLACE&lt;/code&gt; refused, 1845)&lt;/td&gt;
&lt;td&gt;1.65 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither MySQL nor PostgreSQL can alter a check constraint's expression, so changing the list means &lt;code&gt;DROP CHECK&lt;/code&gt; and &lt;code&gt;ADD CONSTRAINT ... CHECK&lt;/code&gt;, and adding a check makes MySQL read every row to validate it. MySQL does that by copying the table, even when the new list only adds a value and no row could fail it. The &lt;code&gt;DROP CHECK&lt;/code&gt; alone is instant; it is the &lt;code&gt;ADD&lt;/code&gt; that costs. PostgreSQL can split the cost with &lt;code&gt;NOT VALID&lt;/code&gt; and a later &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt;; MySQL has no equivalent. Its &lt;code&gt;NOT ENFORCED&lt;/code&gt; check is added instantly, but it enforces nothing, and switching it to &lt;code&gt;ENFORCED&lt;/code&gt; copies the table.&lt;/p&gt;

&lt;p&gt;A copy is not only slow, it holds back writes. On the same table grown to 3,000,000 rows, inserting &lt;code&gt;'review'&lt;/code&gt; in the middle of the list copied for 4.94 seconds. A single-row &lt;code&gt;INSERT&lt;/code&gt; sent 0.7 seconds in waited 4.33 seconds, while a &lt;code&gt;SELECT&lt;/code&gt; by primary key sent at the same moment came back at once. Appending &lt;code&gt;'archived'&lt;/code&gt; to the same 3,000,000 rows took 0.00 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does adding a value to a MySQL ENUM copy the table?
&lt;/h2&gt;

&lt;p&gt;Not if the value goes at the end and the list stays within the same storage width.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;ENUM&lt;/code&gt; takes 1 byte per row up to 255 values and 2 bytes from 256 up to its limit of 65,535; a &lt;code&gt;SET&lt;/code&gt; takes 1, 2, 3, 4 or 8 bytes for its up to 64 members, per the &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/storage-requirements.html" rel="noopener noreferrer"&gt;storage requirements table&lt;/a&gt;. When an append crosses one of those widths, every row has to be rewritten at the new width, so the append copies the table like any other change. That is why the 9th &lt;code&gt;SET&lt;/code&gt; member copied and the 10th did not, and why the 256th &lt;code&gt;ENUM&lt;/code&gt; value copied. Few lists ever reach 255 values, but 8 &lt;code&gt;SET&lt;/code&gt; members is an easy boundary to cross.&lt;/p&gt;

&lt;p&gt;To make the cost visible before production, name the algorithm in the migration:&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;tasks&lt;/span&gt;
  &lt;span class="no"&gt;MODIFY&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="no"&gt;ENUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'open'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'done'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'blocked'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'archived'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'open'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="no"&gt;ALGORITHM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="no"&gt;INSTANT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Query OK, 0 rows affected (0.00 sec)&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt;
  &lt;span class="no"&gt;MODIFY&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="no"&gt;ENUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'open'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'review'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'done'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'blocked'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'open'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="no"&gt;ALGORITHM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="no"&gt;INSTANT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- ERROR 1846 (0A000): ALGORITHM=INSTANT is not supported.&lt;/span&gt;
&lt;span class="c1"&gt;-- Reason: Need to rebuild the table to change column type. Try ALGORITHM=COPY/INPLACE.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second statement changes nothing and says why. Without the clause it would have copied the table, because MySQL picks the cheapest algorithm it can and does not name it; the only sign is &lt;code&gt;1000000 rows affected&lt;/code&gt; instead of &lt;code&gt;0 rows affected&lt;/code&gt;, after the copy has run. The same clause catches a migration generated from a model where someone inserted &lt;code&gt;'review'&lt;/code&gt; where it reads well, rather than at the end.&lt;/p&gt;

&lt;p&gt;Order matters for a second reason. MySQL &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/enum.html" rel="noopener noreferrer"&gt;sorts an &lt;code&gt;ENUM&lt;/code&gt; by position&lt;/a&gt;, not alphabetically, so &lt;code&gt;ORDER BY status&lt;/code&gt; on this table returns &lt;code&gt;open&lt;/code&gt;, &lt;code&gt;done&lt;/code&gt;, &lt;code&gt;blocked&lt;/code&gt;. Appending keeps that order stable; putting a new value anywhere but last in a sort means inserting it or reordering the list, and both copy the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I remove a value from a MySQL ENUM?
&lt;/h2&gt;

&lt;p&gt;Move every row off the value first, then remove it, and expect the removal to copy the table.&lt;/p&gt;

&lt;p&gt;With strict mode on, the MySQL 8.4 default, removing a value that any row holds fails before anything changes: &lt;code&gt;ERROR 1265 (01000): Data truncated for column 'status' at row 2&lt;/code&gt;. Without strict mode, the same statement succeeds with one warning per row, 36 here, and those rows now hold the empty string, which the &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/enum.html" rel="noopener noreferrer"&gt;&lt;code&gt;ENUM&lt;/code&gt; documentation&lt;/a&gt; calls the error value, with index 0. &lt;code&gt;SELECT status + 0&lt;/code&gt; shows it. Nothing tells the application that 36 tasks lost their status. So before the change:&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;SELECT&lt;/span&gt; &lt;span class="nb"&gt;COUNT&lt;/span&gt;&lt;span class="cm"&gt;(*) FROM tasks WHERE status = 'blocked';   -- 36
UPDATE tasks SET status = 'open' WHERE status = 'blocked';
ALTER TABLE tasks MODIFY status ENUM('open', 'done') NOT NULL DEFAULT 'open';
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Renaming a value is the same three steps: append the new name (instant), move the rows with an &lt;code&gt;UPDATE&lt;/code&gt;, remove the old name (a copy). On a large table the &lt;code&gt;UPDATE&lt;/code&gt; is the expensive step, one transaction over every row that holds the value, so run it in batches and have the application accept both names while the rows move. Changing &lt;code&gt;'done'&lt;/code&gt; to &lt;code&gt;'completed'&lt;/code&gt; in place is refused &lt;code&gt;INSTANT&lt;/code&gt; with 1846 and fails with 1265 on rows holding &lt;code&gt;'done'&lt;/code&gt;. There is no shorter route, with one exception: a change of letter case only. Under a case-insensitive collation such as the default &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt;, relabelling &lt;code&gt;'done'&lt;/code&gt; as &lt;code&gt;'Done'&lt;/code&gt; was instant and every row read back as &lt;code&gt;Done&lt;/code&gt;. Under &lt;code&gt;utf8mb4_bin&lt;/code&gt; or &lt;code&gt;utf8mb4_0900_as_cs&lt;/code&gt; the same statement failed with 1265, because there &lt;code&gt;'done'&lt;/code&gt; and &lt;code&gt;'Done'&lt;/code&gt; are different values. The default follows the same rule: &lt;code&gt;DEFAULT 'Open'&lt;/code&gt; on a list holding &lt;code&gt;'open'&lt;/code&gt; is accepted under &lt;code&gt;_ci&lt;/code&gt; and stored as &lt;code&gt;'open'&lt;/code&gt;, and refused on a &lt;code&gt;status ENUM('open','done') COLLATE utf8mb4_bin&lt;/code&gt; column with &lt;code&gt;ERROR 1067 (42000): Invalid default value for 'status'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the copy is too expensive, the alternative is not to remove the value at all: leave it in the list, stop the application writing it, and remove it the next time the table is rebuilt for another reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which should you use: ENUM, CHECK or a lookup table?
&lt;/h2&gt;

&lt;p&gt;Use an &lt;code&gt;ENUM&lt;/code&gt; for a list that only grows at the end, a lookup table for a list that changes often or carries data of its own, and a &lt;code&gt;CHECK&lt;/code&gt; when portability matters more than the cost of changing it.&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;ENUM&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;VARCHAR&lt;/code&gt; + &lt;code&gt;CHECK&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Lookup table + foreign key&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add a value&lt;/td&gt;
&lt;td&gt;Instant at the end; a copy anywhere else&lt;/td&gt;
&lt;td&gt;Copies the table&lt;/td&gt;
&lt;td&gt;An &lt;code&gt;INSERT&lt;/code&gt;, no &lt;code&gt;ALTER TABLE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove a value rows still hold&lt;/td&gt;
&lt;td&gt;Fails, 1265 (strict)&lt;/td&gt;
&lt;td&gt;Fails, 3819 (&lt;code&gt;Check constraint 'c_status_check' is violated.&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Fails, 1451 (&lt;code&gt;Cannot delete or update a parent row&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage per row&lt;/td&gt;
&lt;td&gt;1 byte (up to 255 values)&lt;/td&gt;
&lt;td&gt;The string itself&lt;/td&gt;
&lt;td&gt;The string or a numeric key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sort order&lt;/td&gt;
&lt;td&gt;Position in the list&lt;/td&gt;
&lt;td&gt;Collation order&lt;/td&gt;
&lt;td&gt;By label after a join, or a sort column you add&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Moves to PostgreSQL or SQL Server&lt;/td&gt;
&lt;td&gt;Needs converting&lt;/td&gt;
&lt;td&gt;As written&lt;/td&gt;
&lt;td&gt;As written&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The removal row matters as much as the add row: every option refuses to orphan a row, but only the lookup table does it without an &lt;code&gt;ALTER TABLE&lt;/code&gt; on the table that holds the rows. Its cost is a join to read the label and one more table in the diagram.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity shows what an ENUM change costs
&lt;/h2&gt;

&lt;p&gt;Schemity's impact analysis reports which MySQL &lt;code&gt;ENUM&lt;/code&gt; edits copy the table and how many rows hold a value you removed, before the migration runs.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. After you &lt;a href="https://schemity.com/doc/connect-mysql/" rel="noopener noreferrer"&gt;connect to MySQL&lt;/a&gt;, an &lt;code&gt;ENUM&lt;/code&gt; column's values appear in the field dialog's Values box, and editing them there becomes a planned &lt;code&gt;ALTER TABLE ... MODIFY&lt;/code&gt; statement.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu23wxw0aa25cm5p5779p.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu23wxw0aa25cm5p5779p.webp" alt="The tasks table read from a local MySQL 8.4 database in Schemity: id INTEGER auto, status ENUM with the default open underlined, title VARCHAR(100) defaulting to an empty string, and created_at DATETIME defaulting to NOW()" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;Impact analysis&lt;/a&gt; applies the rules measured above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Append a value and there is no finding, because MySQL changes only the definition. Append the 256th &lt;code&gt;ENUM&lt;/code&gt; value or the 9th &lt;code&gt;SET&lt;/code&gt; member and the table rewrite appears.&lt;/li&gt;
&lt;li&gt;Insert, reorder or remove a value and the findings include "Rewrites tasks to change status" with the row estimate, and a note that changing the type of status holds back writes to &lt;code&gt;tasks&lt;/code&gt; while it reads the same estimated rows.&lt;/li&gt;
&lt;li&gt;Remove a value and a "Can fail on existing data" finding appears ("Loses data" on a server without strict mode), with "Count exactly" to count the rows that still hold it. The count runs only when you ask, as a read-only &lt;code&gt;SELECT COUNT(*)&lt;/code&gt; that matches the removed values, so 36 &lt;code&gt;blocked&lt;/code&gt; rows show up before MySQL reports them as error 1265.&lt;/li&gt;
&lt;li&gt;A change of letter case is matched the way the column's collation compares: no finding under &lt;code&gt;_ci&lt;/code&gt;, a failing removal under &lt;code&gt;_bin&lt;/code&gt; or &lt;code&gt;_cs&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Appending &lt;code&gt;archived&lt;/code&gt; after &lt;code&gt;open&lt;/code&gt;, &lt;code&gt;done&lt;/code&gt; and &lt;code&gt;blocked&lt;/code&gt; in the Values box is the first case, and the Impact drawer stays empty:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl8op586ptrgmwebnfosd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl8op586ptrgmwebnfosd.webp" alt="Schemity's field dialog for tasks.status: type ENUM, Values open, done, blocked and archived, charset utf8mb4, collation utf8mb4_0900_ai_ci, default open" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0exptjhr0cmaz3ouges0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0exptjhr0cmaz3ouges0.webp" alt="Schemity's Impact drawer after archived is appended to tasks.status on MySQL 8.4: Impact (0), No findings, because MySQL changes only the table definition" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Remove &lt;code&gt;blocked&lt;/code&gt; instead and the same drawer shows three findings: the change can fail on existing data, it rewrites &lt;code&gt;tasks&lt;/code&gt;, and it holds back writes while it reads the estimated ~997.6K rows, 42 MB on disk.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3i2g9xiwgr4qd427v58q.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3i2g9xiwgr4qd427v58q.webp" alt="Schemity's field dialog for tasks.status with blocked removed: Values open and done, collation utf8mb4_0900_ai_ci, default open" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frfqmsvwxcfwpjnxg17dr.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frfqmsvwxcfwpjnxg17dr.webp" alt="Schemity's Impact drawer after blocked is removed from tasks.status on MySQL 8.4: Impact (3), Can fail on existing data with Changes the type of tasks.status, fails if a value does not fit, ~997.6K rows and a Count exactly button; Rewrites the table with Rewrites tasks to change status, ~997.6K rows, 42 MB on disk; and Holds back other statements with Changing the type of status holds back writes to tasks" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekhtayfxkajkumqd1va8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekhtayfxkajkumqd1va8.webp" alt="The Findings panel for the same change: the planned migration ALTER TABLE tasks MODIFY COLUMN status ENUM('open','done') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'open', with no ALGORITHM clause, one planned change, Lint 0 and the three impact findings" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;Schema lint&lt;/a&gt; adds one rule for the same column type: &lt;code&gt;mysql-enum-default-not-listed&lt;/code&gt; reports an &lt;code&gt;ENUM&lt;/code&gt; or &lt;code&gt;SET&lt;/code&gt; default that is not one of its values, which MySQL refuses with error 1067, compared under the column's collation. That is how the diagram lets you see the impact of every change to a value list: the append and the insert look alike in a migration file, and only one of them copies the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  MySQL ENUM changes, in one list
&lt;/h2&gt;

&lt;p&gt;Only an append within the same storage width, or a change of letter case under a &lt;code&gt;_ci&lt;/code&gt; collation, is free; everything else rewrites the table or fails.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Appending to an &lt;code&gt;ENUM&lt;/code&gt; or &lt;code&gt;SET&lt;/code&gt; is &lt;code&gt;INSTANT&lt;/code&gt;, unless it crosses 255 &lt;code&gt;ENUM&lt;/code&gt; values or a &lt;code&gt;SET&lt;/code&gt; storage width (8, 16, 24 or 32 members).&lt;/li&gt;
&lt;li&gt;Inserting in the middle, reordering and removing a value copy the table and hold back writes until the copy ends.&lt;/li&gt;
&lt;li&gt;Removing a value that rows hold fails with 1265 in strict mode and silently empties those rows without it; move them first.&lt;/li&gt;
&lt;li&gt;Changing an enforced &lt;code&gt;CHECK&lt;/code&gt; list copies the table every time, appends included; a lookup table changes with an &lt;code&gt;INSERT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;ALGORITHM=INSTANT&lt;/code&gt; to an append so MySQL refuses, rather than copies, if the change is not one, and set &lt;code&gt;lock_wait_timeout&lt;/code&gt; so even an instant change cannot queue reads behind an open transaction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other MySQL &lt;code&gt;ALTER TABLE&lt;/code&gt; changes that copy, from a &lt;code&gt;VARCHAR&lt;/code&gt; crossing 255 bytes to a foreign key added with checks on, are timed in &lt;a href="https://schemity.com/blog/mysql-alter-table-lock-instant-inplace-copy/" rel="noopener noreferrer"&gt;MySQL ALTER TABLE: which changes lock the table&lt;/a&gt;, and what happens to the statements before a failed one is covered in &lt;a href="https://schemity.com/blog/transactional-ddl-postgres-mysql-cockroachdb/" rel="noopener noreferrer"&gt;which failed migrations roll back&lt;/a&gt;. The same choice on PostgreSQL, where enums are a type of their own, is compared in &lt;a href="https://schemity.com/blog/postgres-enum-vs-check-constraint-vs-lookup-table/" rel="noopener noreferrer"&gt;Postgres enum vs check constraint vs lookup table&lt;/a&gt;, and if you are &lt;a href="https://schemity.com/blog/mysql-to-postgresql-schema-conversion/" rel="noopener noreferrer"&gt;converting a MySQL schema to PostgreSQL&lt;/a&gt;, every &lt;code&gt;ENUM&lt;/code&gt; needs that decision anyway.&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>database</category>
      <category>sql</category>
      <category>devops</category>
    </item>
    <item>
      <title>MariaDB to MySQL Migration: Which Schema Features Break, and How to Find Them First</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Tue, 10 Nov 2026 01:19:17 +0000</pubDate>
      <link>https://dev.to/tbson87/mariadb-to-mysql-migration-which-schema-features-break-and-how-to-find-them-first-3jk8</link>
      <guid>https://dev.to/tbson87/mariadb-to-mysql-migration-which-schema-features-break-and-how-to-find-them-first-3jk8</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 MariaDB schema is not a MySQL schema once it uses anything MariaDB added on its own: &lt;code&gt;UUID&lt;/code&gt;, &lt;code&gt;INET6&lt;/code&gt; and &lt;code&gt;INET4&lt;/code&gt; columns, sequences, system-versioned tables and the &lt;code&gt;uca1400&lt;/code&gt; collations all stop a MySQL 8.4 import, and a &lt;code&gt;JSON&lt;/code&gt; column comes out of the dump as &lt;code&gt;LONGTEXT&lt;/code&gt; with a default MySQL refuses. Six &lt;code&gt;information_schema&lt;/code&gt; queries list the affected columns and tables before you try, and the MySQL replacement for each is a choice, not a rename.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Moving a schema from MariaDB to MySQL works only for the parts both databases share, and since MariaDB 10.x the parts they do not share are the ones teams pick MariaDB for. A plain &lt;code&gt;mariadb-dump&lt;/code&gt; of a three-table MariaDB 11.4 schema with one sequence, loaded into MySQL 8.4, failed on every &lt;code&gt;CREATE&lt;/code&gt; in it: the sequence, a table with &lt;code&gt;UUID&lt;/code&gt; and &lt;code&gt;INET6&lt;/code&gt; columns, a table whose default draws on the sequence, and a table whose only visible problem was its collation. Each failure has a MySQL replacement, but the replacement changes the column, so it is a design decision you make per column, not a find-and-replace.&lt;/p&gt;

&lt;p&gt;The schema below is what was tested, on &lt;code&gt;mariadb:11.4&lt;/code&gt; (11.4.13) and &lt;code&gt;mysql:8.4&lt;/code&gt; (8.4.11) in throwaway containers. It is the kind of thing a MariaDB shop accumulates without trying: a native &lt;code&gt;UUID&lt;/code&gt; for public ids, an &lt;code&gt;INET6&lt;/code&gt; for the last login address, a &lt;code&gt;JSON&lt;/code&gt; preferences column, a generated total, a sequence for invoice numbers, and system versioning on orders so support can see what an order looked like last week.&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;BIGINT&lt;/span&gt; &lt;span class="no"&gt;UNSIGNED&lt;/span&gt; &lt;span class="no"&gt;AUTO_INCREMENT&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="n"&gt;public_id&lt;/span&gt; &lt;span class="kt"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="kt"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="kt"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_ip&lt;/span&gt; &lt;span class="no"&gt;INET6&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;signup_ip&lt;/span&gt; &lt;span class="no"&gt;INET4&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;prefs&lt;/span&gt; &lt;span class="kt"&gt;JSON&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&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="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;BIGINT&lt;/span&gt; &lt;span class="no"&gt;UNSIGNED&lt;/span&gt; &lt;span class="no"&gt;AUTO_INCREMENT&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="n"&gt;customer_id&lt;/span&gt; &lt;span class="kt"&gt;BIGINT&lt;/span&gt; &lt;span class="no"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;qty&lt;/span&gt; &lt;span class="kt"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;unit_price&lt;/span&gt; &lt;span class="kt"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="kt"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qty&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;unit_price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="no"&gt;PERSISTENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="kt"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'new'&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="no"&gt;SYSTEM&lt;/span&gt; &lt;span class="no"&gt;VERSIONING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="no"&gt;SEQUENCE&lt;/span&gt; &lt;span class="n"&gt;invoice_no&lt;/span&gt; &lt;span class="no"&gt;START&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;BIGINT&lt;/span&gt; &lt;span class="no"&gt;UNSIGNED&lt;/span&gt; &lt;span class="no"&gt;AUTO_INCREMENT&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="n"&gt;order_id&lt;/span&gt; &lt;span class="kt"&gt;BIGINT&lt;/span&gt; &lt;span class="no"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;number&lt;/span&gt; &lt;span class="kt"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;NEXT&lt;/span&gt; &lt;span class="no"&gt;VALUE&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;invoice_no&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Can you migrate MariaDB to MySQL with a dump?
&lt;/h2&gt;

&lt;p&gt;You can when the schema uses only what both databases share, and the dump of this one failed on every MariaDB-only feature it touched.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mariadb-dump --no-data shop&lt;/code&gt;, piped into &lt;code&gt;mysql --force&lt;/code&gt; on 8.4, produced these errors, in order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Statement in the dump&lt;/th&gt;
&lt;th&gt;MySQL 8.4 said&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DROP SEQUENCE IF EXISTS invoice_no&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ERROR 1064 (42000)&lt;/code&gt;: syntax error near &lt;code&gt;SEQUENCE IF EXISTS&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CREATE SEQUENCE invoice_no ...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ERROR 1064 (42000)&lt;/code&gt;: syntax error near &lt;code&gt;SEQUENCE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DO SETVAL(invoice_no, 1000, 0)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ERROR 1305 (42000): FUNCTION shop.SETVAL does not exist&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CREATE TABLE customers&lt;/code&gt; with &lt;code&gt;uuid&lt;/code&gt; and &lt;code&gt;inet6&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ERROR 1064 (42000)&lt;/code&gt;: syntax error near &lt;code&gt;uuid NOT NULL DEFAULT uuid()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CREATE TABLE invoices&lt;/code&gt; with &lt;code&gt;DEFAULT nextval(shop.invoice_no)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ERROR 1064 (42000)&lt;/code&gt;: syntax error near &lt;code&gt;nextval(&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CREATE TABLE orders ... COLLATE=utf8mb4_uca1400_ai_ci&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ERROR 1273 (HY000): Unknown collation: 'utf8mb4_uca1400_ai_ci'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fixing those exposed three more. A &lt;code&gt;CHAR(36)&lt;/code&gt; column with the dump's &lt;code&gt;DEFAULT uuid()&lt;/code&gt; is still a syntax error on MySQL, which wants an expression default in parentheses, &lt;code&gt;DEFAULT (UUID())&lt;/code&gt;. With that rewritten, &lt;code&gt;customers&lt;/code&gt; failed on &lt;code&gt;ERROR 1101 (42000): BLOB, TEXT, GEOMETRY or JSON column 'prefs' can't have a default value&lt;/code&gt;, and &lt;code&gt;orders&lt;/code&gt; failed on a syntax error near &lt;code&gt;SYSTEM VERSIONING&lt;/code&gt;. MySQL's parser stops at the first problem in each statement, so the first error list is never the whole list. That is why it pays to find everything before the first attempt, with the queries further down.&lt;/p&gt;

&lt;p&gt;The collation row is the one that surprises people, because nothing in the schema asked for it. &lt;code&gt;utf8mb4_uca1400_ai_ci&lt;/code&gt; is the server default on MariaDB 11.4 (&lt;code&gt;SELECT @@collation_server&lt;/code&gt; returned it on 11.4.13), so every table created without an explicit collation carries it, and MySQL has never had it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which MariaDB schema features does MySQL 8.4 not accept?
&lt;/h2&gt;

&lt;p&gt;Six MariaDB features have no MySQL equivalent under the same name (&lt;code&gt;UUID&lt;/code&gt;, &lt;code&gt;INET6&lt;/code&gt;, &lt;code&gt;INET4&lt;/code&gt;, sequences, system versioning and the &lt;code&gt;uca1400&lt;/code&gt; collations), &lt;code&gt;JSON&lt;/code&gt; changes meaning on the way, &lt;code&gt;PERSISTENT&lt;/code&gt; needs renaming, and expression defaults need parentheses.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MariaDB feature&lt;/th&gt;
&lt;th&gt;What MySQL 8.4 does with it&lt;/th&gt;
&lt;th&gt;What to use in MySQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;UUID&lt;/code&gt; type (MariaDB 10.7+)&lt;/td&gt;
&lt;td&gt;Syntax error&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;BINARY(16)&lt;/code&gt; with &lt;code&gt;DEFAULT (UUID_TO_BIN(UUID(), 1))&lt;/code&gt;, or &lt;code&gt;CHAR(36)&lt;/code&gt; with &lt;code&gt;DEFAULT (UUID())&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;INET6&lt;/code&gt;, &lt;code&gt;INET4&lt;/code&gt; types (&lt;code&gt;INET4&lt;/code&gt; from 10.10)&lt;/td&gt;
&lt;td&gt;Syntax error&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;VARBINARY(16)&lt;/code&gt; with &lt;code&gt;INET6_ATON()&lt;/code&gt;, or &lt;code&gt;VARCHAR(45)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;JSON&lt;/code&gt; (an alias of &lt;code&gt;LONGTEXT&lt;/code&gt; plus a check)&lt;/td&gt;
&lt;td&gt;Arrives as &lt;code&gt;longtext&lt;/code&gt; with &lt;code&gt;CHECK (json_valid(...))&lt;/code&gt;; a literal default fails&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;JSON&lt;/code&gt;, with &lt;code&gt;DEFAULT ('{}')&lt;/code&gt; in parentheses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CREATE SEQUENCE&lt;/code&gt;, &lt;code&gt;NEXT VALUE FOR&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Syntax error&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AUTO_INCREMENT&lt;/code&gt;, or a one-row counter table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expression defaults such as &lt;code&gt;uuid()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Syntax error without parentheses&lt;/td&gt;
&lt;td&gt;The same expression in parentheses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WITH SYSTEM VERSIONING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Syntax error&lt;/td&gt;
&lt;td&gt;A plain table plus a history table, or no history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;utf8mb4_uca1400_ai_ci&lt;/code&gt; (11.4 default)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ERROR 1273&lt;/code&gt;: unknown collation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PERSISTENT&lt;/code&gt; generated column&lt;/td&gt;
&lt;td&gt;Syntax error in hand-written DDL&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;STORED&lt;/code&gt; (the dump already writes &lt;code&gt;STORED&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;VECTOR(n)&lt;/code&gt; (MariaDB 11.7+)&lt;/td&gt;
&lt;td&gt;Syntax error: MySQL 8.4 has no &lt;code&gt;VECTOR&lt;/code&gt; (it arrived in MySQL 9.0)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;VARBINARY&lt;/code&gt;/&lt;code&gt;BLOB&lt;/code&gt; holding the packed floats, or MySQL 9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;JSON&lt;/code&gt; row needs the most thought, because nothing fails if the default is left out. MariaDB's &lt;a href="https://mariadb.com/docs/server/reference/data-types/string-data-types/json" rel="noopener noreferrer"&gt;JSON page&lt;/a&gt; says "&lt;code&gt;JSON&lt;/code&gt; is an alias for &lt;code&gt;LONGTEXT COLLATE utf8mb4_bin&lt;/code&gt; introduced for compatibility reasons with MySQL's JSON data type", and &lt;code&gt;SHOW CREATE TABLE&lt;/code&gt; on MariaDB prints &lt;code&gt;prefs longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '{}' CHECK (json_valid(prefs))&lt;/code&gt;. MySQL 8.4 accepts that column once the default is written as &lt;code&gt;DEFAULT ('{}')&lt;/code&gt;, keeps the check, and rejects &lt;code&gt;'{bad'&lt;/code&gt; with &lt;code&gt;ERROR 3819&lt;/code&gt;. But it is a text column, not MySQL's binary &lt;code&gt;JSON&lt;/code&gt;. Change it to &lt;code&gt;JSON&lt;/code&gt; on purpose; a dump will not.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;UUID&lt;/code&gt; and &lt;code&gt;INET&lt;/code&gt; rows are real choices. &lt;code&gt;BINARY(16)&lt;/code&gt; with &lt;code&gt;UUID_TO_BIN(UUID(), 1)&lt;/code&gt; keeps the 16-byte storage and, for values generated by &lt;code&gt;UUID()&lt;/code&gt; (version 1), the time-ordered index layout MariaDB's &lt;code&gt;UUID&lt;/code&gt; type gave you, but every query that prints the value needs &lt;code&gt;BIN_TO_UUID(public_id, 1)&lt;/code&gt;, and every query that searches by it, and the data load itself, needs &lt;code&gt;UUID_TO_BIN&lt;/code&gt;. &lt;code&gt;CHAR(36)&lt;/code&gt; keeps the queries unchanged and costs 36 bytes per value plus a random-order index. The same trade applies to &lt;code&gt;VARBINARY(16)&lt;/code&gt; against &lt;code&gt;VARCHAR(45)&lt;/code&gt; for addresses, where &lt;code&gt;INET6_ATON('2001:db8::1')&lt;/code&gt; and &lt;code&gt;INET6_NTOA(last_ip)&lt;/code&gt; are the conversions. The trade between key types is the subject of &lt;a href="https://schemity.com/blog/postgres-uuid-vs-bigint-primary-key/" rel="noopener noreferrer"&gt;UUID vs bigint primary keys&lt;/a&gt;; the short version is that the binary form is the one that keeps the index small.&lt;/p&gt;

&lt;p&gt;System versioning is the row with data at stake. The history rows live in the same table, hidden from a plain &lt;code&gt;SELECT&lt;/code&gt;, and MySQL has nowhere to put them. On MariaDB the table also refuses to change: an &lt;code&gt;ALTER TABLE orders ADD COLUMN ...&lt;/code&gt; failed with &lt;code&gt;ERROR 4119 (HY000)&lt;/code&gt;, which refuses the change on a system-versioned table until &lt;code&gt;@@system_versioning_alter_history&lt;/code&gt; is changed. A default &lt;code&gt;mariadb-dump&lt;/code&gt; writes only the current rows; &lt;code&gt;--dump-history&lt;/code&gt; adds the old versions with their &lt;code&gt;row_start&lt;/code&gt; and &lt;code&gt;row_end&lt;/code&gt; values, which a MySQL table has no columns for. Decide whether the history matters before the move. If it does, copy it out with &lt;code&gt;SELECT ... FROM orders FOR SYSTEM_TIME ALL&lt;/code&gt; into an ordinary &lt;code&gt;orders_history&lt;/code&gt; table and fill that table from the application or a trigger on MySQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you find the MariaDB-only features in your schema?
&lt;/h2&gt;

&lt;p&gt;Six queries against &lt;code&gt;information_schema&lt;/code&gt; on the MariaDB server list the columns, tables and defaults that will not move as written, before anything is dumped.&lt;/p&gt;

&lt;p&gt;Run them in the database you plan to move, on MariaDB itself, where these values are reported. On the test schema they returned exactly the problem list from the dump, plus &lt;code&gt;signup_ip&lt;/code&gt;, which the dump never reached:&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="c1"&gt;-- 1. Column types MySQL does not have&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'uuid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'inet6'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'inet4'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. JSON columns (stored as LONGTEXT with a json_valid check)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;constraint_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_clause&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check_constraints&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;constraint_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;check_clause&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'json_valid(%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. System-versioned tables and sequences&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_type&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;table_type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SYSTEM VERSIONED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SEQUENCE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. Expression defaults, sequence defaults included&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column_default&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;column_default&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%(%'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;column_default&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'current_timestamp(%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 5. Columns with a collation MySQL does not have&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collation_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;collation_name&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%uca1400%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 6. Tables with that collation as their default&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_collation&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;table_collation&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%uca1400%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Query 4 matters twice. MariaDB reports an expression default without the parentheses MySQL requires (&lt;code&gt;uuid()&lt;/code&gt;, &lt;code&gt;concat('a','b')&lt;/code&gt;), and it reports &lt;code&gt;NEXT VALUE FOR invoice_no&lt;/code&gt; as a &lt;code&gt;nextval()&lt;/code&gt; call on the sequence, so a search of the migration files for &lt;code&gt;NEXT VALUE&lt;/code&gt; finds the column a developer wrote, not the one an ORM generated. On the test schema it returned &lt;code&gt;public_id&lt;/code&gt; and &lt;code&gt;invoices.number&lt;/code&gt;. Query 2 is the way to tell a &lt;code&gt;JSON&lt;/code&gt; column from a plain &lt;code&gt;LONGTEXT&lt;/code&gt;, because &lt;code&gt;data_type&lt;/code&gt; is &lt;code&gt;longtext&lt;/code&gt; for both.&lt;/p&gt;

&lt;p&gt;The six results are a list. To see what the list means for the rest of the schema, you also need the foreign keys, the tables that reference &lt;code&gt;orders&lt;/code&gt;, and the columns that copy &lt;code&gt;customers.public_id&lt;/code&gt; into other tables, and that is where a picture of the schema is faster than more queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the reverse direction, MySQL to MariaDB, work better?
&lt;/h2&gt;

&lt;p&gt;Mostly yes: a MySQL 8.4 table with a &lt;code&gt;JSON&lt;/code&gt; column and the &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt; collation loaded into MariaDB 11.4.13 unchanged, and a functional index was the part that failed.&lt;/p&gt;

&lt;p&gt;MariaDB reads MySQL's &lt;code&gt;JSON&lt;/code&gt; as its alias, so the column arrived as &lt;code&gt;longtext ... CHECK (json_valid(attrs))&lt;/code&gt;. MySQL's default collation is accepted because the &lt;a href="https://mariadb.com/docs/release-notes/community-server/11.4/11.4.5" rel="noopener noreferrer"&gt;MariaDB 11.4.5 release notes&lt;/a&gt; list "Added support for utf8mb4_0900_* collations", so check the server's version before relying on it. What failed was the index &lt;code&gt;KEY sku_color ((CAST(attrs-&amp;gt;&amp;gt;'$.color' AS CHAR(20))))&lt;/code&gt;, a MySQL functional key part (the dump writes it out as a &lt;code&gt;json_extract&lt;/code&gt; expression), which MariaDB 11.4 rejected as a syntax error. On MariaDB that index is written as a virtual generated column holding the expression, with an ordinary index on the column.&lt;/p&gt;

&lt;p&gt;One column-level difference runs the same way. MySQL 8.4 accepts &lt;code&gt;n INT NOT NULL INVISIBLE&lt;/code&gt; with no default, and MariaDB 11.4 refuses the same column with &lt;code&gt;ERROR 4108 (HY000): Invisible column 'n' must have a default value&lt;/code&gt;, because an &lt;code&gt;INSERT&lt;/code&gt; that lists no columns would have nothing to put in it. Give every invisible &lt;code&gt;NOT NULL&lt;/code&gt; column a default before moving it to MariaDB.&lt;/p&gt;

&lt;p&gt;So the two directions are not symmetric. MariaDB has spent several releases accepting MySQL's syntax, and MySQL has not done the same for MariaDB's. A move from MariaDB to MySQL is the one that needs the audit. The other direction mostly needs the &lt;code&gt;JSON&lt;/code&gt; caveat: MariaDB's JSON page also says row-based replication "will not work for &lt;code&gt;JSON&lt;/code&gt; types from MySQL to MariaDB", because one side stores text and the other binary.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Tested result&lt;/th&gt;
&lt;th&gt;Main blocker&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MariaDB 11.4 to MySQL 8.4&lt;/td&gt;
&lt;td&gt;Failed on every MariaDB-only feature&lt;/td&gt;
&lt;td&gt;Types, sequences, versioning, &lt;code&gt;uca1400&lt;/code&gt; collation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL 8.4 to MariaDB 11.4&lt;/td&gt;
&lt;td&gt;Loaded, apart from one index&lt;/td&gt;
&lt;td&gt;Functional key parts, invisible &lt;code&gt;NOT NULL&lt;/code&gt; columns with no default&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How to see a MariaDB schema before you move it
&lt;/h2&gt;

&lt;p&gt;Connect Schemity to the MariaDB server and the diagram shows every table with the types MariaDB reports, so the columns from the audit can be read in place, next to the tables that depend on them.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. From the next release MariaDB is its own database type, supported from 11.4 onwards: pick &lt;strong&gt;MariaDB&lt;/strong&gt; in &lt;a href="https://schemity.com/doc/connect-mysql/" rel="noopener noreferrer"&gt;the connection dialog&lt;/a&gt; or paste a &lt;code&gt;mariadb://&lt;/code&gt; URL, and the diagram footer shows the server it read. The reader was tested on MariaDB 11.4.13, 11.8.9, 12.3.3 and 13.0.2 and read this schema the same on all four; the screenshot below is from 13.0.2. &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;Reverse engineering&lt;/a&gt; then reads the whole schema, and the test schema came out as MariaDB stores it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;customers.public_id&lt;/code&gt; as &lt;code&gt;UUID&lt;/code&gt;, &lt;code&gt;last_ip&lt;/code&gt; as &lt;code&gt;INET6&lt;/code&gt; and &lt;code&gt;signup_ip&lt;/code&gt; as &lt;code&gt;INET4&lt;/code&gt;, so the first audit query's results are visible as column types on the canvas.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;customers.prefs&lt;/code&gt; as &lt;code&gt;LONGTEXT&lt;/code&gt; with its &lt;code&gt;json_valid(prefs)&lt;/code&gt; check, which is what MariaDB means by &lt;code&gt;JSON&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;orders&lt;/code&gt;, a system-versioned table, read like any other table, with its row count and dependents.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;invoices.number&lt;/code&gt; with its &lt;code&gt;nextval()&lt;/code&gt; default on the sequence. The sequence itself is not a table and is not drawn.&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpisfhs26sqj3zh1qym5m.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpisfhs26sqj3zh1qym5m.webp" alt="Schemity's canvas reading the shop schema from a MariaDB 13.0.2 server, footer showing MariaDB: 13.0.2 and Entities: 3, Relations: 2: customers has id as an unsigned BIGINT marked auto, email as VARCHAR(255) with the U unique marker, last_ip as INET6 and signup_ip as INET4, both nullable, prefs as LONGTEXT defaulting to {} with cc: 1 in the table footer for its json_valid check, and public_id as UUID defaulting to uuid(); orders has id and customer_id as unsigned BIGINT, qty as INTEGER, status as VARCHAR(20) defaulting to new, total as DECIMAL(12,2) generated from qty times unit_price, and unit_price as DECIMAL(10,2); invoices has id and order_id as unsigned BIGINT and number as BIGINT defaulting to nextval on the invoice_no sequence; relationship lines run from customers to orders and from orders to invoices" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is what it takes to understand your schema before a move like this: the column types, which tables hold the columns that change, and every foreign key into them, on one canvas. &lt;code&gt;public_id&lt;/code&gt; and &lt;code&gt;last_ip&lt;/code&gt; are in &lt;code&gt;customers&lt;/code&gt;, which &lt;code&gt;orders&lt;/code&gt; references, which &lt;code&gt;invoices&lt;/code&gt; references, so the &lt;code&gt;UUID&lt;/code&gt; decision touches every query that joins those three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can Schemity write the MySQL version of a MariaDB schema?
&lt;/h2&gt;

&lt;p&gt;Yes, from the next release: the SQL dialog writes a MariaDB diagram as MySQL DDL, and the script it wrote for the test schema ran on MySQL 8.4.11 with no errors, all three tables and both foreign keys created.&lt;/p&gt;

&lt;p&gt;Open the SQL dialog with &lt;strong&gt;Export &amp;gt; SQL file&lt;/strong&gt; and pick MySQL in its &lt;strong&gt;Database&lt;/strong&gt; dropdown (&lt;a href="https://schemity.com/doc/export-sql-ddl/" rel="noopener noreferrer"&gt;converting SQL to another database&lt;/a&gt;). The script makes the conservative choice from the table above for each MariaDB-only feature and lists every one in a comment block at the top, so the review is the list, not a diff:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In the MariaDB diagram&lt;/th&gt;
&lt;th&gt;In the MySQL script&lt;/th&gt;
&lt;th&gt;Said in the notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;public_id UUID DEFAULT uuid()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CHAR(36) DEFAULT (UUID())&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MySQL has no &lt;code&gt;UUID&lt;/code&gt; type: stored as its 36-character text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;last_ip INET6&lt;/code&gt;, &lt;code&gt;signup_ip INET4&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VARCHAR(45)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MySQL has no network address type: stored as text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;prefs LONGTEXT ... DEFAULT '{}'&lt;/code&gt; with &lt;code&gt;json_valid&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LONGTEXT DEFAULT ('{}')&lt;/code&gt;, check kept&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;number DEFAULT nextval(invoice_no)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No default&lt;/td&gt;
&lt;td&gt;The default draws from a MariaDB sequence, which MySQL has none of: left out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;utf8mb4_uca1400_ai_ci&lt;/code&gt; on every table&lt;/td&gt;
&lt;td&gt;No collation, so MySQL 8.4 used &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4 collation settings left out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;total ... PERSISTENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GENERATED ALWAYS AS (...) STORED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; keys&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BIGINT UNSIGNED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgcp40vq4akqky1cb6e72.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgcp40vq4akqky1cb6e72.webp" alt="Schemity's Diagram SQL dialog on the MariaDB shop diagram with Database set to MySQL: the conversion report says it converted from MariaDB to MySQL with the diagram unchanged, and lists INET4 and INET6 to VARCHAR(45) because MySQL has no network address type, UUID to CHAR(36) stored as its 36-character text, invoices.number's default nextval on the invoice_no sequence left out because MySQL has no sequences, invoices.number's auto-increment left out because MySQL numbers only a key column, and 4 character set or collation settings left out for MySQL to pick its own; below it the 41-line script opens with the same notes as comments, with Copy and Save SQL file buttons" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What it does not decide is the part of the move that is a design choice. &lt;code&gt;CHAR(36)&lt;/code&gt; is the choice that leaves queries unchanged; if you want &lt;code&gt;BINARY(16)&lt;/code&gt;, edit those columns in the script. The &lt;code&gt;prefs&lt;/code&gt; column stays text with its check, the same as the dump, so changing it to MySQL &lt;code&gt;JSON&lt;/code&gt; is still a deliberate edit. &lt;code&gt;invoices.number&lt;/code&gt; now needs its value from the application or a counter table. And &lt;code&gt;orders&lt;/code&gt; is created as a plain table: its history is not part of the schema, so it has to be copied out before the move as described above. The diagram itself is left as MariaDB, so the MariaDB design and the MySQL script never drift into two models.&lt;/p&gt;

&lt;p&gt;If the MariaDB model lives in Navicat Data Modeler rather than a running server, &lt;a href="https://schemity.com/doc/import-from-other-tools/" rel="noopener noreferrer"&gt;Import Navicat&lt;/a&gt; reads a MariaDB &lt;code&gt;.nmodel&lt;/code&gt; into a MariaDB diagram with its &lt;code&gt;UUID&lt;/code&gt;, &lt;code&gt;INET6&lt;/code&gt; and &lt;code&gt;INET4&lt;/code&gt; columns kept native, and the same SQL dialog writes it for MySQL. Converting to another database is a licensed feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to decide before moving a schema from MariaDB to MySQL
&lt;/h2&gt;

&lt;p&gt;Every MariaDB-only feature in the audit needs a MySQL replacement chosen before the dump, and the &lt;code&gt;UUID&lt;/code&gt;, &lt;code&gt;INET&lt;/code&gt;, sequence and versioning choices change application queries, not only DDL.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run the six audit queries on the MariaDB server and keep the output as the migration checklist.&lt;/li&gt;
&lt;li&gt;Set every table to &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt;, or another collation MySQL has, in the dump or before it.&lt;/li&gt;
&lt;li&gt;For each &lt;code&gt;UUID&lt;/code&gt; column, choose &lt;code&gt;BINARY(16)&lt;/code&gt; (smaller index, conversion functions in every query) or &lt;code&gt;CHAR(36)&lt;/code&gt; (unchanged queries, larger index). Do the same for &lt;code&gt;INET6&lt;/code&gt; and &lt;code&gt;INET4&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Change &lt;code&gt;JSON&lt;/code&gt; columns to MySQL &lt;code&gt;JSON&lt;/code&gt; on purpose, and write their defaults in parentheses.&lt;/li&gt;
&lt;li&gt;Replace each sequence with &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; or a counter table, and put every expression default from query 4 in parentheses.&lt;/li&gt;
&lt;li&gt;Decide whether system-versioned history is kept. If it is, copy it out with &lt;code&gt;FOR SYSTEM_TIME ALL&lt;/code&gt; before the move.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Moving between engines with more distance between them, such as &lt;a href="https://schemity.com/blog/mysql-to-postgresql-schema-conversion/" rel="noopener noreferrer"&gt;MySQL to PostgreSQL&lt;/a&gt;, follows the same pattern of type decisions at a larger scale. For a MySQL schema that is staying put, &lt;a href="https://schemity.com/blog/mysql-alter-table-lock-instant-inplace-copy/" rel="noopener noreferrer"&gt;which MySQL ALTER TABLE changes lock the table&lt;/a&gt; is the next question, and &lt;a href="https://schemity.com/blog/navicat-data-modeler-alternative/" rel="noopener noreferrer"&gt;the Navicat Data Modeler alternative post&lt;/a&gt; shows a MariaDB shop model read from a live MariaDB 11.4 server.&lt;/p&gt;

</description>
      <category>mariadb</category>
      <category>mysql</category>
      <category>database</category>
      <category>sql</category>
    </item>
    <item>
      <title>PostgreSQL Composite Foreign Keys vs Row-Level Security: Which Keeps Tenants Apart?</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Tue, 10 Nov 2026 01:18:44 +0000</pubDate>
      <link>https://dev.to/tbson87/postgresql-composite-foreign-keys-vs-row-level-security-which-keeps-tenants-apart-2h5d</link>
      <guid>https://dev.to/tbson87/postgresql-composite-foreign-keys-vs-row-level-security-which-keeps-tenants-apart-2h5d</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; Row-level security decides which rows a session can read and write, but PostgreSQL foreign key checks bypass it, so an Acme task can still be assigned to a Globex member the session cannot even see. A composite foreign key such as &lt;code&gt;(company_id, assignee_id)&lt;/code&gt; referencing &lt;code&gt;members (company_id, id)&lt;/code&gt; makes the database itself refuse that row. They close different holes: keep the composite keys for references between tenant tables, and add row-level security when queries need a second line of defence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Row-level security stops a query from seeing another tenant's rows; it does not stop a row from pointing at one. PostgreSQL foreign key checks bypass every policy, so with row-level security enabled and filtering correctly, an Acme task can still be assigned to a Globex member that the Acme session cannot even read. The constraint that refuses that row is a composite foreign key, &lt;code&gt;(company_id, assignee_id)&lt;/code&gt; referencing &lt;code&gt;members (company_id, id)&lt;/code&gt;, and the two features answer different questions.&lt;/p&gt;

&lt;p&gt;I found this reviewing a multi-company task tracker that an AI agent designed, I clicked a foreign key on &lt;code&gt;tasks.assignee_member_id&lt;/code&gt; and &lt;code&gt;company_id&lt;/code&gt; lit up with it, on both tables. The &lt;code&gt;members&lt;/code&gt; table had a primary key on &lt;code&gt;id&lt;/code&gt; and, beside it, &lt;code&gt;UNIQUE (company_id, id)&lt;/code&gt;, a constraint that adds no uniqueness at all. In my whole career I had never seen one. It turns out to be the cheapest way to make a multi-tenant database refuse cross-tenant data, and it has three traps that are easy to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does row-level security stop a row from referencing another tenant?
&lt;/h2&gt;

&lt;p&gt;No: the PostgreSQL manual says foreign key references always bypass row security, and on PostgreSQL 18.3 a cross-tenant reference goes straight through.&lt;/p&gt;

&lt;p&gt;Here is the usual setup: two companies, one member each, &lt;code&gt;tasks&lt;/code&gt; and &lt;code&gt;members&lt;/code&gt; both scoped by a policy on &lt;code&gt;company_id&lt;/code&gt;, and an &lt;code&gt;app&lt;/code&gt; role that is not the table owner.&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;companies&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;   &lt;span class="kt"&gt;bigint&lt;/span&gt; &lt;span class="no"&gt;GENERATED&lt;/span&gt; &lt;span class="no"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="no"&gt;IDENTITY&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="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="kt"&gt;bigint&lt;/span&gt; &lt;span class="no"&gt;GENERATED&lt;/span&gt; &lt;span class="no"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="no"&gt;IDENTITY&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="n"&gt;company_id&lt;/span&gt; &lt;span class="kt"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;companies&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt;       &lt;span class="kt"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="kt"&gt;bigint&lt;/span&gt; &lt;span class="no"&gt;GENERATED&lt;/span&gt; &lt;span class="no"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="no"&gt;IDENTITY&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="n"&gt;company_id&lt;/span&gt;  &lt;span class="kt"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;companies&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;assignee_id&lt;/span&gt; &lt;span class="kt"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;       &lt;span class="kt"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;companies&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Acme'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Globex'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;company_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Ann'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Bob'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="no"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="no"&gt;LOGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;companies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app&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="n"&gt;members&lt;/span&gt; &lt;span class="no"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="no"&gt;LEVEL&lt;/span&gt; &lt;span class="no"&gt;SECURITY&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="n"&gt;tasks&lt;/span&gt;   &lt;span class="no"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="no"&gt;LEVEL&lt;/span&gt; &lt;span class="no"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="no"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;members_tenant&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;company_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.company_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="no"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tasks_tenant&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;company_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.company_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;company_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.company_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As &lt;code&gt;app&lt;/code&gt; with &lt;code&gt;SET app.company_id = '1'&lt;/code&gt;, the policies work: &lt;code&gt;SELECT count(*) FROM members&lt;/code&gt; returns 1, and &lt;code&gt;SELECT ... FROM members WHERE id = 2&lt;/code&gt; returns nothing, because Bob belongs to Globex. Writing a Globex task is refused with &lt;code&gt;new row violates row-level security policy for table "tasks"&lt;/code&gt;. And then:&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;company_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assignee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cross-tenant'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- INSERT 0 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Acme now has a task assigned to Bob. The row passes &lt;code&gt;WITH CHECK&lt;/code&gt;, because its own &lt;code&gt;company_id&lt;/code&gt; is Acme's, and the foreign key finds member 2, because the check does not go through the policy. The &lt;a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html" rel="noopener noreferrer"&gt;PostgreSQL manual&lt;/a&gt; says so directly: referential integrity checks, foreign key references included, always bypass row security to keep data integrity. That is the right call for integrity and the wrong assumption for isolation: a bug that passes the wrong id, or a request that sends one, writes a reference no policy will ever stop.&lt;/p&gt;

&lt;p&gt;Two more facts from the same session decide how much weight row-level security can carry. The table owner bypasses the policies unless the table is altered with &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt; (an owner role saw both rows until then), and superusers and roles with &lt;code&gt;BYPASSRLS&lt;/code&gt; always bypass them. And the setting is easy to get wrong on a pooled connection. A connection that never set it fails with &lt;code&gt;unrecognized configuration parameter "app.company_id"&lt;/code&gt;, but a plain &lt;code&gt;SET&lt;/code&gt; lasts for the whole connection: after one request ran &lt;code&gt;SET app.company_id = '2'&lt;/code&gt;, the next transaction on that connection set nothing and still counted Globex's member. Set it with &lt;code&gt;SET LOCAL&lt;/code&gt;, or &lt;code&gt;set_config('app.company_id', ..., true)&lt;/code&gt;, inside every transaction, so it ends with the transaction; a later transaction that forgets then fails with &lt;code&gt;invalid input syntax for type bigint: ""&lt;/code&gt; instead of reading another tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does a composite foreign key keep tenants apart?
&lt;/h2&gt;

&lt;p&gt;It makes the reference carry the tenant: the child's &lt;code&gt;company_id&lt;/code&gt; must match the parent's, so the pair has to exist in the parent as one row.&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;members&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;members_company_id_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="n"&gt;company_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;tasks&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;tasks_assignee_id_fkey&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="n"&gt;tasks&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;tasks_company_id_assignee_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="n"&gt;company_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assignee_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;company_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignee_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the same insert is refused: &lt;code&gt;insert or update on table "tasks" violates foreign key constraint "tasks_company_id_assignee_id_fkey"&lt;/code&gt;, while assigning Ann, who is in Acme, still works. No policy, setting or role is involved; the database proves the reference with the same machinery that proves any foreign key, and the check runs for the table owner and superusers too. The one way past it is a superuser switching triggers off: with &lt;code&gt;SET session_replication_role = replica&lt;/code&gt;, the same cross-tenant insert went through, which is what restore and replication tools do on purpose.&lt;/p&gt;

&lt;p&gt;The unique constraint looks redundant and is required. A foreign key may only point at columns declared as the primary key or a unique constraint, as exactly that list, and PostgreSQL does not reason that &lt;code&gt;(company_id, id)&lt;/code&gt; is unique because &lt;code&gt;id&lt;/code&gt; is. Without it, the key is refused with &lt;code&gt;there is no unique constraint matching given keys for referenced table "members"&lt;/code&gt;. Its only job is to be the target, plus the index the check uses. The same pattern on a junction table, so a member can only hold roles of their own tenant, is worked through in &lt;a href="https://schemity.com/blog/design-roles-and-permissions-for-multi-tenant-rbac/" rel="noopener noreferrer"&gt;the multi-tenant RBAC post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks when you add a composite tenant key?
&lt;/h2&gt;

&lt;p&gt;Three things: &lt;code&gt;ON DELETE SET NULL&lt;/code&gt; clears the tenant column, swapped columns are accepted silently, and MySQL wants the columns in index order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SET NULL&lt;/code&gt; without a column list.&lt;/strong&gt; A plain &lt;code&gt;ON DELETE SET NULL&lt;/code&gt; clears every column of the key. On the composite key that includes &lt;code&gt;company_id&lt;/code&gt;, which is &lt;code&gt;NOT NULL&lt;/code&gt;, so deleting the member fails: &lt;code&gt;null value in column "company_id" ... violates not-null constraint&lt;/code&gt;, from an internal &lt;code&gt;UPDATE ... SET "company_id" = NULL, "assignee_id" = NULL&lt;/code&gt;. PostgreSQL 15 &lt;a href="https://www.postgresql.org/docs/15/release-15.html" rel="noopener noreferrer"&gt;added the column list&lt;/a&gt;, &lt;code&gt;ON DELETE SET NULL (assignee_id)&lt;/code&gt;, which clears only the member column; with it, deleting Ann leaves the task in Acme with no assignee. MySQL and SQL Server have no list, MySQL 8.4 refuses to create the plain &lt;code&gt;SET NULL&lt;/code&gt; key at all (&lt;code&gt;ERROR 1830: Column 'company_id' cannot be NOT NULL: needed in a foreign key constraint&lt;/code&gt;), and CockroachDB 26.3 refuses the clause, so on those use &lt;code&gt;CASCADE&lt;/code&gt;, &lt;code&gt;RESTRICT&lt;/code&gt; or &lt;code&gt;NO ACTION&lt;/code&gt; for these keys. The general choice between those actions is in &lt;a href="https://schemity.com/blog/postgres-on-delete-set-null-vs-cascade/" rel="noopener noreferrer"&gt;ON DELETE SET NULL vs CASCADE&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Columns pair by position, not by name.&lt;/strong&gt; &lt;code&gt;FOREIGN KEY (assignee_id, company_id) REFERENCES members (company_id, id)&lt;/code&gt; is created without complaint, because both columns are integers, and then compares &lt;code&gt;assignee_id&lt;/code&gt; with &lt;code&gt;company_id&lt;/code&gt;. With a third company, Initech, and a third member, Cy (id 3, in Acme), that key refused the correct row (company 1, assignee 3) with &lt;code&gt;Key (assignee_id, company_id)=(3, 1) is not present&lt;/code&gt; and accepted an Initech task assigned to Ann of Acme (company 3, assignee 1). Nothing fails at creation; it fails on the first real rows, and some of the rows it lets through cross tenants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MySQL wants index order.&lt;/strong&gt; PostgreSQL accepts the referenced columns in any order as long as some unique constraint covers that set. MySQL 8.4.11 accepted &lt;code&gt;REFERENCES members (company_id, id)&lt;/code&gt; against &lt;code&gt;UNIQUE (company_id, id)&lt;/code&gt; and refused &lt;code&gt;REFERENCES members (id, company_id)&lt;/code&gt; with error 6125, &lt;code&gt;Missing unique key for constraint ... in the referenced table&lt;/code&gt;. Writing the tenant first everywhere avoids it.&lt;/p&gt;

&lt;p&gt;The index is less of a trap than it looks. The check that runs when a member is deleted searches for &lt;code&gt;company_id = ? AND assignee_id = ?&lt;/code&gt;, and an index leading with &lt;code&gt;assignee_id&lt;/code&gt; alone serves it: on 100,000 rows, PostgreSQL 18.3 used &lt;code&gt;ix_tasks_assignee&lt;/code&gt; for that lookup, because the member id already names one parent row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composite foreign key vs row-level security: which should you use?
&lt;/h2&gt;

&lt;p&gt;Use composite foreign keys wherever tenant tables reference each other, and add row-level security when queries need a second guard; they close different holes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Composite foreign key&lt;/th&gt;
&lt;th&gt;Row-level security&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stops a query reading another tenant's rows&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, for roles it applies to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stops writing a row for another tenant&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes: &lt;code&gt;WITH CHECK&lt;/code&gt;, or &lt;code&gt;USING&lt;/code&gt; when no &lt;code&gt;WITH CHECK&lt;/code&gt; is given&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stops a reference to another tenant's row&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, foreign key checks bypass it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Applies to the table owner and superusers&lt;/td&gt;
&lt;td&gt;Yes, unless a superuser turns triggers off&lt;/td&gt;
&lt;td&gt;Owner only with &lt;code&gt;FORCE&lt;/code&gt;; superusers and &lt;code&gt;BYPASSRLS&lt;/code&gt; never&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs per-session setup&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;With the setting pattern used here, &lt;code&gt;SET LOCAL&lt;/code&gt; in every transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema cost&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;company_id&lt;/code&gt; on child tables, &lt;code&gt;UNIQUE (company_id, id)&lt;/code&gt; on parents, wider foreign keys&lt;/td&gt;
&lt;td&gt;One policy per table&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The composite key is a structural guarantee: once it exists, no ordinary write can store a cross-tenant reference, including migrations, admin scripts and the next service someone writes. Row-level security is a query guarantee: it catches the missing &lt;code&gt;WHERE company_id = ?&lt;/code&gt; that every multi-tenant codebase eventually ships. A schema that has only the policies looks isolated and can still hold Acme tasks assigned to Globex people; a schema with only composite keys can still leak a list of every tenant's members to a query that forgot its filter.&lt;/p&gt;

&lt;p&gt;The cost of the keys is real but bounded. Every table that references a tenant table carries &lt;code&gt;company_id&lt;/code&gt;, which it usually has anyway, and every referenced table gets one extra unique constraint. Tables outside the tenancy, such as a global country list, keep ordinary single-column keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you design a composite tenant key in Schemity?
&lt;/h2&gt;

&lt;p&gt;Draw the relationship as usual: when the parent has a unique constraint holding its primary key plus a tenant column, Schemity makes the composite key for you.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. Since v2.14.5, dragging a relationship from &lt;code&gt;members&lt;/code&gt; to &lt;code&gt;tasks&lt;/code&gt; when &lt;code&gt;members&lt;/code&gt; has &lt;code&gt;UNIQUE (company_id, id)&lt;/code&gt; creates &lt;code&gt;(company_id, member_id)&lt;/code&gt; referencing &lt;code&gt;members (company_id, id)&lt;/code&gt;. If &lt;code&gt;tasks&lt;/code&gt; already has &lt;code&gt;company_id&lt;/code&gt;, it is shared; if not, it is added as &lt;code&gt;NOT NULL&lt;/code&gt;, and the relation dialog shows that row locked so you can see which. A checkbox keeps the plain key for a table outside the tenancy. The details are in &lt;a href="https://schemity.com/doc/relationships/" rel="noopener noreferrer"&gt;Relationships&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The traps above are handled where you design the next one, not after the migration runs. &lt;code&gt;SET NULL&lt;/code&gt; on such a key is written as &lt;code&gt;ON DELETE SET NULL (member_id)&lt;/code&gt; on PostgreSQL and refused with the reason on the databases that cannot limit it. A key read from a database keeps its column list instead of losing it on the next migration. And &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;schema lint&lt;/a&gt; catches both mistakes before they ship: &lt;code&gt;redundant-fk-path&lt;/code&gt; flags &lt;code&gt;tasks&lt;/code&gt; when it and &lt;code&gt;members&lt;/code&gt; both reference &lt;code&gt;companies&lt;/code&gt; but the key from &lt;code&gt;tasks&lt;/code&gt; to &lt;code&gt;members&lt;/code&gt; leaves &lt;code&gt;company_id&lt;/code&gt; out, the key that let an Acme task go to a Globex member, and names the composite key that fixes it; &lt;code&gt;fk-columns-crossed&lt;/code&gt; flags a key whose columns are paired crosswise, the swapped key that is created and then refuses correct rows.&lt;/p&gt;

&lt;p&gt;If an AI agent designs your schema, the open-source &lt;a href="https://schemity.com/doc/ai-assisted-design/" rel="noopener noreferrer"&gt;schema design skill&lt;/a&gt; makes it decide &lt;code&gt;ON DELETE&lt;/code&gt; per key, name every constraint, and check its design with Schemity's lint before handing it back. It was one of those agent-designed schemas that put this key in front of me.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>saas</category>
    </item>
    <item>
      <title>Transactional DDL in PostgreSQL, MySQL and CockroachDB: Which Failed Migrations Roll Back?</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Sat, 07 Nov 2026 17:53:23 +0000</pubDate>
      <link>https://dev.to/tbson87/transactional-ddl-in-postgresql-mysql-and-cockroachdb-which-failed-migrations-roll-back-3bkp</link>
      <guid>https://dev.to/tbson87/transactional-ddl-in-postgresql-mysql-and-cockroachdb-which-failed-migrations-roll-back-3bkp</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; If the third statement of a migration fails, PostgreSQL, SQL Server and SQLite undo the whole migration, and MySQL and CockroachDB do not. MySQL commits before (and after) every DDL statement, so earlier statements and any data changes made before them stay. CockroachDB does the same by default through &lt;code&gt;autocommit_before_ddl&lt;/code&gt;, and with that setting off it can still commit the data changes while undoing the schema change.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Whether a failed migration rolls back depends on the database as much as on the &lt;code&gt;BEGIN&lt;/code&gt; around it. With the migration in one transaction and a rollback on error, PostgreSQL, SQL Server and SQLite undo every statement when the third one fails. MySQL and CockroachDB keep the first two, and both also keep any data change the same transaction made before them. On CockroachDB, turning off the setting that causes this can still leave the data change committed while the schema change is undone.&lt;/p&gt;

&lt;p&gt;The migration below was run on each database as it comes out of the box, against a &lt;code&gt;customers&lt;/code&gt; table with two rows, one of them with a &lt;code&gt;NULL&lt;/code&gt; email:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;-- a data fix&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;-- statement 1&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;customers_phone_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;-- statement 2&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="k"&gt;SET&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="c1"&gt;-- statement 3 fails&lt;/span&gt;
&lt;span class="k"&gt;ROLLBACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Statement 3 was always going to fail: one row has no email. The versions were PostgreSQL 18.3, MySQL 8.4.11 and 9.7.4, CockroachDB v26.3.4, SQL Server 2022 (CU26) and SQLite 3.54.0, each in a throwaway container or in memory. MySQL spells the &lt;code&gt;NOT NULL&lt;/code&gt; change &lt;code&gt;MODIFY email VARCHAR(255) NOT NULL&lt;/code&gt;. SQL Server needs &lt;code&gt;BEGIN TRANSACTION&lt;/code&gt;, &lt;code&gt;ADD phone NVARCHAR(30) NULL&lt;/code&gt; without the word &lt;code&gt;COLUMN&lt;/code&gt;, and &lt;code&gt;ALTER COLUMN email NVARCHAR(255) NOT NULL&lt;/code&gt;. SQLite cannot add &lt;code&gt;NOT NULL&lt;/code&gt; to an existing column, so its third statement was a &lt;code&gt;UNIQUE&lt;/code&gt; index that the data breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when a migration fails halfway?
&lt;/h2&gt;

&lt;p&gt;On PostgreSQL, SQL Server and SQLite nothing is left behind; on MySQL and CockroachDB the statements before the failure stay applied.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;After statement 3 fails&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL 18.3&lt;/td&gt;
&lt;td&gt;Nothing changed: no &lt;code&gt;phone&lt;/code&gt;, no index, &lt;code&gt;credit&lt;/code&gt; unchanged&lt;/td&gt;
&lt;td&gt;DDL runs inside the transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL Server 2022&lt;/td&gt;
&lt;td&gt;Nothing changed&lt;/td&gt;
&lt;td&gt;DDL runs inside the transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite 3.54&lt;/td&gt;
&lt;td&gt;Nothing changed&lt;/td&gt;
&lt;td&gt;DDL runs inside the transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL 8.4 and 9.7&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;phone&lt;/code&gt; and its index exist, &lt;code&gt;credit = 100&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Every DDL statement commits the open transaction first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CockroachDB 26.3, default&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;phone&lt;/code&gt; and its index exist, &lt;code&gt;credit = 100&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;autocommit_before_ddl&lt;/code&gt; commits before each schema change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CockroachDB 26.3, setting off, table unlocked&lt;/td&gt;
&lt;td&gt;The new column was not added, but the &lt;code&gt;UPDATE&lt;/code&gt; stayed&lt;/td&gt;
&lt;td&gt;The schema change failed after the transaction committed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The MySQL and CockroachDB rows are the ones to look at closely. The &lt;code&gt;ROLLBACK&lt;/code&gt; at the end ran without an error on both and changed nothing. The data fix at the top, which the developer wrote inside &lt;code&gt;BEGIN&lt;/code&gt; because they wanted it undone on failure, was committed the moment the first &lt;code&gt;ALTER TABLE&lt;/code&gt; arrived.&lt;/p&gt;

&lt;p&gt;One condition sits under the first three rows: something has to roll back. After a failed statement, PostgreSQL refuses everything except &lt;code&gt;ROLLBACK&lt;/code&gt;, and turns a &lt;code&gt;COMMIT&lt;/code&gt; into a rollback. SQLite undoes only the failed statement and leaves the transaction open, so a script that sends &lt;code&gt;COMMIT&lt;/code&gt; next keeps statements 1 and 2. Migration tools, Schemity included, roll back on the first error, which is the case the table shows.&lt;/p&gt;

&lt;p&gt;A schema left halfway is worse than a migration that did nothing. The migration tool records the migration as failed. Running it again then fails on statement 1 instead of statement 3, because &lt;code&gt;phone&lt;/code&gt; already exists, so someone has to work out by hand which statements ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does PostgreSQL roll back DDL, and what does that cost?
&lt;/h2&gt;

&lt;p&gt;PostgreSQL rolls back DDL because &lt;code&gt;ALTER TABLE&lt;/code&gt; and &lt;code&gt;CREATE INDEX&lt;/code&gt; run inside the transaction like any &lt;code&gt;UPDATE&lt;/code&gt;, and the price is that their locks last until &lt;code&gt;COMMIT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Inside the migration above, just before statement 3, &lt;code&gt;pg_locks&lt;/code&gt; held two locks on &lt;code&gt;customers&lt;/code&gt;: &lt;code&gt;AccessExclusiveLock&lt;/code&gt; from the &lt;code&gt;ADD COLUMN&lt;/code&gt; and &lt;code&gt;ShareLock&lt;/code&gt; from the &lt;code&gt;CREATE INDEX&lt;/code&gt;. &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; blocks every read and write on the table, and it is not released when the &lt;code&gt;ADD COLUMN&lt;/code&gt; finishes. It is released when the transaction ends. A migration that adds a column and then backfills a million rows in the same transaction blocks the table for the whole backfill. That is why &lt;a href="https://schemity.com/blog/postgres-add-not-null-column-large-table/" rel="noopener noreferrer"&gt;adding a NOT NULL column to a large table&lt;/a&gt; splits the work into separate steps with a &lt;code&gt;lock_timeout&lt;/code&gt; on each.&lt;/p&gt;

&lt;p&gt;Two statements do not fit inside the rule, and PostgreSQL 18.3 rejected or limited both in a transaction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; fails with &lt;code&gt;ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block&lt;/code&gt;. The &lt;a href="https://www.postgresql.org/docs/current/sql-createindex.html" rel="noopener noreferrer"&gt;&lt;code&gt;CREATE INDEX&lt;/code&gt; page&lt;/a&gt; says it directly: "a regular &lt;code&gt;CREATE INDEX&lt;/code&gt; command can be performed within a transaction block, but &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; cannot." It needs a migration of its own, with the transaction turned off for that migration.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ALTER TYPE tier ADD VALUE 'team'&lt;/code&gt; runs, but &lt;code&gt;SELECT 'team'::tier&lt;/code&gt; in the same transaction fails with &lt;code&gt;unsafe use of new value "team" of enum type tier&lt;/code&gt;. The &lt;a href="https://www.postgresql.org/docs/current/sql-altertype.html" rel="noopener noreferrer"&gt;&lt;code&gt;ALTER TYPE&lt;/code&gt; notes&lt;/a&gt; say the new value "cannot be used until after the transaction has been committed", so a migration that adds an enum value and backfills rows with it in one transaction fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Can you roll back a schema change in MySQL?
&lt;/h2&gt;

&lt;p&gt;No: MySQL commits the open transaction before every DDL statement, and again after it, so a &lt;code&gt;ROLLBACK&lt;/code&gt; after an &lt;code&gt;ALTER TABLE&lt;/code&gt; has nothing left to undo.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html" rel="noopener noreferrer"&gt;implicit commit page&lt;/a&gt; lists &lt;code&gt;ALTER TABLE&lt;/code&gt;, &lt;code&gt;CREATE INDEX&lt;/code&gt; and &lt;code&gt;CREATE TABLE&lt;/code&gt; among the statements that "implicitly end any transaction active in the current session, as if you had done a &lt;code&gt;COMMIT&lt;/code&gt; before executing the statement". In the test, that is exactly what happened on 8.4.11 and on 9.7.4. Statement 3 failed with &lt;code&gt;ERROR 1138 (22004): Invalid use of NULL value&lt;/code&gt;. The &lt;code&gt;ROLLBACK&lt;/code&gt; succeeded. And &lt;code&gt;customers&lt;/code&gt; still had &lt;code&gt;phone&lt;/code&gt;, &lt;code&gt;customers_phone_idx&lt;/code&gt; and &lt;code&gt;credit = 100&lt;/code&gt; on row 1.&lt;/p&gt;

&lt;p&gt;MySQL 8.0 introduced atomic DDL, which is easy to read as the fix, and the &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/atomic-ddl.html" rel="noopener noreferrer"&gt;atomic DDL page&lt;/a&gt; says it is not: "Atomic DDL is not transactional DDL." It makes one statement all-or-nothing. A crash in the middle of an &lt;code&gt;ALTER TABLE&lt;/code&gt; no longer leaves the data dictionary and the table files disagreeing. A migration of five statements is still five commits.&lt;/p&gt;

&lt;p&gt;So on MySQL, the unit of rollback is the statement. Statement-at-a-time behaviour also explains why a MySQL change can be slow and still safe: how long each statement holds the table is a separate question, measured in &lt;a href="https://schemity.com/blog/mysql-alter-table-lock-instant-inplace-copy/" rel="noopener noreferrer"&gt;which MySQL ALTER TABLE changes lock the table&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does CockroachDB roll back a failed migration?
&lt;/h2&gt;

&lt;p&gt;Not by default: CockroachDB commits before each schema change, and turning that off does not make DDL and data changes fail together.&lt;/p&gt;

&lt;p&gt;CockroachDB's &lt;a href="https://docs.cockroachlabs.com/docs/stable/session-variables" rel="noopener noreferrer"&gt;session variables page&lt;/a&gt; describes &lt;code&gt;autocommit_before_ddl&lt;/code&gt; as causing "any DDL statement received during an explicit transaction to auto-commit before executing the schema change", default &lt;code&gt;on&lt;/code&gt;, "because CockroachDB does not fully support multiple schema changes in a single transaction." On v26.3.4 it announced itself on the first &lt;code&gt;ALTER TABLE&lt;/code&gt; with &lt;code&gt;NOTICE: auto-committing transaction before processing DDL due to autocommit_before_ddl setting&lt;/code&gt;. After that, the results matched MySQL: the &lt;code&gt;NOT NULL&lt;/code&gt; check failed, and &lt;code&gt;phone&lt;/code&gt;, its index and the &lt;code&gt;UPDATE&lt;/code&gt; all stayed.&lt;/p&gt;

&lt;p&gt;Turning the setting off is the obvious next step, and on 26.3 it stops at the first &lt;code&gt;ALTER&lt;/code&gt;. &lt;code&gt;SHOW CREATE TABLE&lt;/code&gt; showed the new table as &lt;code&gt;WITH (schema_locked = true)&lt;/code&gt;, and with the setting off, &lt;code&gt;ALTER TABLE customers ADD COLUMN fax STRING&lt;/code&gt; inside &lt;code&gt;BEGIN&lt;/code&gt; was refused: &lt;code&gt;this schema change is disallowed because table "customers" is locked and this operation cannot automatically unlock the table&lt;/code&gt;. CockroachDB's own hint says that locking "improves changefeed performance". Unlocking every table you migrate trades that away.&lt;/p&gt;

&lt;p&gt;With the table unlocked and the setting off, the migration ran inside one transaction, and the result was still not all-or-nothing. CockroachDB validates the &lt;code&gt;NOT NULL&lt;/code&gt; after the transaction commits, and it reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: transaction committed but schema change aborted with error:
(23502): validation of column "email" NOT NULL failed on row: id=2, email=NULL, ...
SQLSTATE: XXA00
HINT: Some of the non-DDL statements may have committed successfully,
but some of the DDL statement(s) failed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Afterwards, &lt;code&gt;fax&lt;/code&gt; did not exist and the &lt;code&gt;UPDATE&lt;/code&gt; in the same transaction had been kept. On CockroachDB, keep data changes and schema changes in separate migrations, whatever the setting says.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should you write migrations for a database that cannot roll them back?
&lt;/h2&gt;

&lt;p&gt;Assume each statement commits on its own, and write the migration so the statement most likely to fail can fail before anything else has changed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check the data first.&lt;/strong&gt; &lt;code&gt;SELECT count(*) FROM customers WHERE email IS NULL&lt;/code&gt; before a &lt;code&gt;NOT NULL&lt;/code&gt;, and a &lt;code&gt;GROUP BY ... HAVING count(*) &amp;gt; 1&lt;/code&gt; before a unique index. The statements that fail most often are the ones that check existing rows: &lt;code&gt;NOT NULL&lt;/code&gt;, &lt;code&gt;CHECK&lt;/code&gt;, &lt;code&gt;UNIQUE&lt;/code&gt; and foreign keys. Lock timeouts, name clashes and type conversions are the other usual causes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the checking statement first where the order allows.&lt;/strong&gt; A migration whose only failing statement runs first fails clean, even on a database that commits each statement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep data changes out of schema migrations.&lt;/strong&gt; A backfill in its own migration can be rerun; one that gets committed as a side effect of the next &lt;code&gt;ALTER TABLE&lt;/code&gt; cannot be told apart from a successful run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep migrations small.&lt;/strong&gt; One schema change per migration on MySQL and CockroachDB means a failure leaves a state the migration tool can describe: done or not done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the reverse statements before you apply.&lt;/strong&gt; On these two databases, &lt;code&gt;DROP INDEX customers_phone_idx&lt;/code&gt; and &lt;code&gt;ALTER TABLE customers DROP COLUMN phone&lt;/code&gt; are your rollback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On PostgreSQL the transaction does the cleanup, so the list there is about locks rather than leftovers. Keep each transaction short and give it a &lt;code&gt;lock_timeout&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity shows which statement will fail before you apply it
&lt;/h2&gt;

&lt;p&gt;Schemity's impact analysis names the statements that can fail on existing rows, and its migration dialog says whether your database will roll back the rest.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. Make &lt;code&gt;customers.email&lt;/code&gt; required on a connected diagram, or let an AI agent propose it, and &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; puts the &lt;code&gt;SET NOT NULL&lt;/code&gt; under &lt;strong&gt;Can fail on existing data&lt;/strong&gt; with the table's estimated row count. Its &lt;strong&gt;Count exactly&lt;/strong&gt; button runs &lt;code&gt;SELECT COUNT(*) FROM customers WHERE email IS NULL&lt;/code&gt; when you ask, so you know before applying whether statement 3 will fail. A new unique index gets the same treatment for duplicates. That is what it means to see the impact of every change: the failure moves from the middle of a migration to the review before it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Febx5c8z67yqkd5pdopn0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Febx5c8z67yqkd5pdopn0.webp" alt="Schemity's Impact drawer for customers.email made NOT NULL on PostgreSQL 18.3: under Can fail on existing data, Sets customers.email NOT NULL, fails if any of ~100K rows is NULL, with Exactly 1000 rows after Count exactly; under Holds back other statements, Checking email for NULL holds back reads and writes to customers while it reads ~100K rows, 8.7 MB on disk" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The second finding is the PostgreSQL cost from earlier in the post: the &lt;code&gt;NULL&lt;/code&gt; check holds back reads and writes to &lt;code&gt;customers&lt;/code&gt; while it scans the table, so the drawer shows both whether statement 3 can fail and what it blocks while it runs. On CockroachDB the same change shows only the first finding: it runs the &lt;code&gt;NULL&lt;/code&gt; check as a background job, and while it checked 2,000,000 rows for 17 seconds, a &lt;code&gt;SELECT&lt;/code&gt;, an &lt;code&gt;INSERT&lt;/code&gt; and an &lt;code&gt;UPDATE&lt;/code&gt; on the table each came back in about a quarter of a second.&lt;/p&gt;

&lt;p&gt;The migration dialog then states what a failure will leave behind, worded for the database:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Connected to&lt;/th&gt;
&lt;th&gt;The dialog says&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL, SQL Server, SQLite&lt;/td&gt;
&lt;td&gt;"All SQL migrations are executed within a transaction, ensuring data integrity and safety. If any errors occur, changes will be automatically rolled back."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;td&gt;"MySQL commits each DDL statement immediately. If a later statement fails, earlier statements remain applied and cannot be rolled back. Review the SQL carefully before applying."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CockroachDB&lt;/td&gt;
&lt;td&gt;"CockroachDB commits each schema change immediately. If a later statement fails, earlier statements remain applied and cannot be rolled back. Review the SQL carefully before applying."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fedahgy64niyvlzw9f6ss.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fedahgy64niyvlzw9f6ss.webp" alt="Schemity's Migration confirm dialog on MySQL 8.4: the finding Sets customers.email NOT NULL, fails if any of ~99.9K rows is NULL, the planned ALTER TABLE customers MODIFY COLUMN email VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, and the warning MySQL commits each DDL statement immediately, earlier statements remain applied and cannot be rolled back" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvnk6swjv1lip8qtmsgj1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvnk6swjv1lip8qtmsgj1.webp" alt="Schemity's Migration confirm dialog on CockroachDB 26.3.4: the finding Sets customers.email NOT NULL, fails if any of ~100K rows is NULL, the planned ALTER TABLE customers ALTER COLUMN email SET NOT NULL, the warning CockroachDB commits each schema change immediately, earlier statements remain applied and cannot be rolled back, and the footer CockroachDB: 26.3.4" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;CockroachDB has its own database type in the connection dialog. A diagram created as PostgreSQL before that still gets the CockroachDB warning, because Schemity also checks the server's version string; without that check, a CockroachDB connection would get PostgreSQL's promise of a rollback. The setup is in &lt;a href="https://schemity.com/doc/connect-postgresql/" rel="noopener noreferrer"&gt;connecting to CockroachDB&lt;/a&gt;. If a statement does fail, the error names the statement's number and the database's message, and a notification repeats it, so a long migration does not hide which statement failed. The &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL&lt;/a&gt; is there to read in full before you press Apply.&lt;/p&gt;

&lt;p&gt;Schemity does not reorder your statements or split a migration into smaller ones, and it does not change &lt;code&gt;autocommit_before_ddl&lt;/code&gt;. It tells you which statement will fail and what the database will keep. The order is still yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transactional DDL by database, in one list
&lt;/h2&gt;

&lt;p&gt;Only some databases undo a failed migration, and on MySQL and CockroachDB a &lt;code&gt;BEGIN&lt;/code&gt; around it does not change that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL, SQL Server and SQLite roll back the whole migration. On PostgreSQL every lock is held until &lt;code&gt;COMMIT&lt;/code&gt;, and &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; must run outside a transaction.&lt;/li&gt;
&lt;li&gt;MySQL commits before and after every DDL statement. Atomic DDL makes each statement all-or-nothing, not the migration, and data changes made before the DDL commit with it.&lt;/li&gt;
&lt;li&gt;CockroachDB commits before each schema change by default. With &lt;code&gt;autocommit_before_ddl&lt;/code&gt; off, locked tables refuse the &lt;code&gt;ALTER&lt;/code&gt;, and unlocked ones can keep the data changes when a schema change fails after commit.&lt;/li&gt;
&lt;li&gt;On MySQL and CockroachDB, check the data first, run the statement that can fail first, keep data changes in their own migration and write the reverse statements before applying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where the failure can be caught before it runs, it usually is a constraint meeting old rows, as with the keys in &lt;a href="https://schemity.com/blog/postgres-deferrable-foreign-keys/" rel="noopener noreferrer"&gt;PostgreSQL deferrable foreign keys&lt;/a&gt;. Knowing which schema actually reached production after a half-applied migration is what &lt;a href="https://schemity.com/blog/postgres-track-schema-changes/" rel="noopener noreferrer"&gt;tracking schema changes in PostgreSQL&lt;/a&gt; and &lt;a href="https://schemity.com/blog/comparing-staging-and-production-database-schemas/" rel="noopener noreferrer"&gt;comparing staging and production schemas&lt;/a&gt; cover.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>mysql</category>
      <category>cockroachdb</category>
      <category>database</category>
    </item>
    <item>
      <title>Navicat Data Modeler Alternative: Database Design Software Without the Enterprise Edition</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Fri, 06 Nov 2026 02:00:24 +0000</pubDate>
      <link>https://dev.to/tbson87/navicat-data-modeler-alternative-database-design-software-without-the-enterprise-edition-12ei</link>
      <guid>https://dev.to/tbson87/navicat-data-modeler-alternative-database-design-software-without-the-enterprise-edition-12ei</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; Navicat Data Modeler 5 draws models for free in its Lite edition, but reverse engineering, Import SQL, SQL export and both synchronize directions are Enterprise only: US$749 perpetual or US$379.99 a year. Schemity reads the live database on every license, prices each schema change before it runs, keeps the diagram as JSON in Git, and opens a Navicat .nmodel file with its table positions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Navicat Data Modeler draws database models in every edition, but only its Enterprise edition can read a database, import or export SQL, or synchronize a model with a database, so the Navicat Data Modeler alternative worth weighing depends on whether you need that round trip. If you need Oracle, MongoDB, or conceptual and Data Vault models, stay with Navicat. If you design the physical schema of a database that already runs, you are paying US$749, or US$379.99 a year, for the part that touches it. Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git.&lt;/p&gt;

&lt;p&gt;This page compares the two as of 5 November 2026, from Navicat's own product pages, feature matrix, store and online manual for Navicat Data Modeler 5, which Navicat released in September 2026 (sometimes called Navicat Modeler): what each edition can do with a real database, what it costs over three years, how the model stays in step with the database, where the model file lives, and where Navicat is the better choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Navicat Data Modeler Edition Can Read a Database?
&lt;/h2&gt;

&lt;p&gt;Only Enterprise: the free Lite edition draws models but cannot reverse engineer a database, import or export SQL, or synchronize in either direction.&lt;/p&gt;

&lt;p&gt;Navicat Data Modeler comes in two editions, the free &lt;strong&gt;Lite&lt;/strong&gt; and the paid &lt;strong&gt;Enterprise&lt;/strong&gt; (sold at a lower price as a Non-Commercial license, which Navicat restricts to "educational and non-profit use"). The &lt;a href="https://www.navicat.com/en/products/navicat-data-modeler-feature-matrix" rel="noopener noreferrer"&gt;feature matrix&lt;/a&gt; splits them like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Lite (free)&lt;/th&gt;
&lt;th&gt;Enterprise&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Conceptual, logical and physical models&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crow's Foot, IDEF1X and UML notation&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layers, notes, images, print to PDF and images&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reverse database to model&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import SQL to model&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export model to SQL&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copying SQL from diagram objects&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Synchronize model to database, and database to model&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compare model workspaces, model conversion&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto layout, data dictionary&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design table schemas with AI&lt;/td&gt;
&lt;td&gt;No&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 &lt;a href="https://www.navicat.com/manual/online_manual/en/navicat_data_modeler_5/win_manual/reverse_to_model.html" rel="noopener noreferrer"&gt;online manual&lt;/a&gt; labels each of the database features "Available only in Enterprise Edition". So Lite is for drawing: you can model a schema in it, but nothing you draw reaches a database, not even as SQL you could copy, and nothing in a database reaches the model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flbwmzz9xp8dd02d7xwxk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flbwmzz9xp8dd02d7xwxk.webp" alt="Navicat Data Modeler 5 with a MariaDB shop model open in Physical View: tables product, review, order_item, customer, payment, orders and address joined by crow's foot lines labelled with their foreign key names such as fk_item_product and fk_review_customer; each column shows only its name and type, such as status: enum and created_at: datetime(3), with no defaults or nullability; the left panel lists the tables, the v_order_summary view, the order_number_seq sequence and ten foreign keys, and the status bar reads 22 objects" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same model features also come inside Navicat Premium 18 Enterprise, which its &lt;a href="https://www.navicat.com/manual/online_manual/en/navicat_18/win_manual/model.html" rel="noopener noreferrer"&gt;manual&lt;/a&gt; marks "Available only in Enterprise Edition", and inside the Enterprise edition of single-database products such as Navicat for MySQL. If your team already owns one of those, you may already have the modeler.&lt;/p&gt;

&lt;p&gt;In Schemity, every license connects to the database. &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;Reverse engineering&lt;/a&gt; reads PostgreSQL (including Supabase and CockroachDB), MySQL, MariaDB, SQL Server, SQLite (including Cloudflare D1) and Snowflake into a diagram, and Import SQL reads a dump or &lt;code&gt;CREATE TABLE&lt;/code&gt; statements. There is no edition to upgrade to.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz7xn1v83phr1urmnlq4s.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz7xn1v83phr1urmnlq4s.webp" alt="Schemity's ERD of the same shop schema read from a live MariaDB 11.4 database, the status bar reading Entities 8, Relations 6 and MySQL 11.4.13-MariaDB at 127.0.0.1 shop; every column shows the type and default the database reports, such as external_id UUID uuid(), last_ip INET6, created_at DATETIME(3) current_timestamp(3) and status ENUM active, generated columns show their expressions, such as line_total = quantity * unit_price and slug = lcase(replace(name, ...)), the view v_order_summary is drawn in italics, U and N badges mark unique and nullable columns, and a minimap in the lower right corner shows the whole diagram" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does Navicat Data Modeler Cost in 2026?
&lt;/h2&gt;

&lt;p&gt;Working against a real database costs US$749 perpetual for Enterprise, with one year of maintenance included, or US$37.99 a month, or US$379.99 a year.&lt;/p&gt;

&lt;p&gt;Verified on Navicat's &lt;a href="https://www.navicat.com/en/store/navicat-data-modeler-plan" rel="noopener noreferrer"&gt;plan page&lt;/a&gt; and &lt;a href="https://www.navicat.com/en/store/navicat-data-modeler" rel="noopener noreferrer"&gt;store&lt;/a&gt; on 5 November 2026, in US dollars, for one user:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Edition&lt;/th&gt;
&lt;th&gt;Perpetual&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;th&gt;Yearly&lt;/th&gt;
&lt;th&gt;Reads a database&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lite&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-Commercial&lt;/td&gt;
&lt;td&gt;US$379&lt;/td&gt;
&lt;td&gt;US$18.99&lt;/td&gt;
&lt;td&gt;US$189.99&lt;/td&gt;
&lt;td&gt;Yes, for educational and non-profit use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;US$749&lt;/td&gt;
&lt;td&gt;US$37.99&lt;/td&gt;
&lt;td&gt;US$379.99&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;A perpetual license includes a year of maintenance, which Navicat says covers "software upgrades, live chat, technical support and key reset requests." At purchase you can add a second year for US$119.84 or a second and third year for US$179.76. The license "is per user base", a key works on Windows, macOS or Linux, and five or more perpetual licenses earn a volume discount. Navicat's site offers local versions by region, and its store runs promotions, so the price you see may differ; the figures here are the US list prices with no coupon applied.&lt;/p&gt;

&lt;p&gt;Over three years with updates throughout, for one engineer:&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;Up front&lt;/th&gt;
&lt;th&gt;Years 2 and 3&lt;/th&gt;
&lt;th&gt;Three years&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Navicat Data Modeler Enterprise, perpetual&lt;/td&gt;
&lt;td&gt;US$749&lt;/td&gt;
&lt;td&gt;US$179.76&lt;/td&gt;
&lt;td&gt;US$928.76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Navicat Data Modeler Enterprise, yearly subscription&lt;/td&gt;
&lt;td&gt;US$379.99&lt;/td&gt;
&lt;td&gt;2 x US$379.99&lt;/td&gt;
&lt;td&gt;US$1,139.97&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schemity&lt;/td&gt;
&lt;td&gt;$129&lt;/td&gt;
&lt;td&gt;2 x $29&lt;/td&gt;
&lt;td&gt;$187&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two licensing details matter on a locked-down machine. A subscription "contacts our licensing server once per hour", and a perpetual key can be activated offline, but only through a second computer with internet access that visits Navicat's activation page. Navicat also says a perpetual license and a subscription "cannot be used at the same Navicat Data Modeler."&lt;/p&gt;

&lt;p&gt;Schemity is $129 once, with a year of updates, an optional $29 a year after that, use on up to three of your machines, and a two-week free trial with every feature. Navicat's own trial is 14 days, and Lite is free for drawing. Schemity's free use after the trial is also design-only, with no time limit, and it still imports SQL, which Lite cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Navicat Data Modeler Keep the Model in Sync With the Database?
&lt;/h2&gt;

&lt;p&gt;Yes, in Enterprise, in both directions: it compares the model with a database and writes a deployment script, and it can pull a database's changes back into the model.&lt;/p&gt;

&lt;p&gt;Credit to Navicat here, because this is a real feature. &lt;a href="https://www.navicat.com/manual/online_manual/en/navicat_data_modeler_5/win_manual/sync_to_database.html" rel="noopener noreferrer"&gt;Synchronize Model to Database&lt;/a&gt; compares a model with a connection, lists the differing objects, and shows two tabs before anything runs: a DDL Comparison of each object's statements on both sides, and a Deployment Script with "the specific scripts that will be executed on the target database". You can reorder the script, and an option to "Continue on error" lets the run carry on past a failing statement. &lt;a href="https://www.navicat.com/manual/online_manual/en/navicat_data_modeler_5/win_manual/sync_to_model.html" rel="noopener noreferrer"&gt;Synchronize Database to Model&lt;/a&gt; runs the same compare the other way.&lt;/p&gt;

&lt;p&gt;What the wizard shows you is SQL. The manual's page for it says nothing about the rows already in the target tables, and that is where the expensive questions live: how many rows a dropped column takes with it, whether an &lt;code&gt;ALTER TABLE ... ADD CONSTRAINT&lt;/code&gt; will fail on data that already breaks it, which views depend on the column, and whether the change rewrites a large table. None of that is in the statement, and the wizard's answer to a statement that fails is an option to carry on past it.&lt;/p&gt;

&lt;p&gt;In Schemity, a connected diagram is read again from the database every time you open it, and &lt;a href="https://schemity.com/doc/resync-database/" rel="noopener noreferrer"&gt;Refresh&lt;/a&gt; re-reads it on demand after a migration ran elsewhere: tables you placed keep their position, dropped ones disappear, and new ones arrive placed clear of the rest. A change you draw becomes a &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL diff&lt;/a&gt; against the real database, and &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; prices it before it runs: the rows a drop would take, the statements that would fail on existing data, the dependent views that would block or break, and the statements that would rewrite or lock a large table. So you see the impact of every change before it runs: nothing is applied until you approve it, and a migration file written by an ORM or an AI agent is analysed the same way without being run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Does a Navicat Model Live?
&lt;/h2&gt;

&lt;p&gt;A current Navicat workspace is one &lt;code&gt;.nmodel&lt;/code&gt; archive that Navicat shares through Navicat Cloud or its On-Prem Server, while a Schemity diagram is a file in the repository next to the code it describes.&lt;/p&gt;

&lt;p&gt;Navicat's release notes name the &lt;code&gt;.ndm2&lt;/code&gt; file of Data Modeler 3 and 4, a single JSON file. Navicat 18's &lt;a href="https://www.navicat.com/manual/online_manual/en/navicat_18/win_manual/model.html" rel="noopener noreferrer"&gt;manual&lt;/a&gt; names the newer format: "Workspace files (.nmodel) are stored under the Profiles Location." We opened the one in Schemity's own test suite, saved by Navicat 18: six models in four dialects, 37 tables in all, make a 268 KB tar archive of 188 gzip-compressed JSON files, one per object and named by UUID, plus a small JSON index.&lt;/p&gt;

&lt;p&gt;Navicat's answer is its own sharing. A workspace can be saved to &lt;a href="https://www.navicat.com/manual/online_manual/en/navicat_data_modeler_5/win_manual/collaboration_cloud.html" rel="noopener noreferrer"&gt;Navicat Cloud&lt;/a&gt; or to a Navicat &lt;a href="https://www.navicat.com/manual/online_manual/en/navicat_data_modeler_5/win_manual/collaboration_onprem.html" rel="noopener noreferrer"&gt;On-Prem Server&lt;/a&gt;, which can push a notification to each member's copy when a shared file changes. That works, and it keeps the model inside Navicat's services rather than next to the code it describes.&lt;/p&gt;

&lt;p&gt;A Schemity diagram is &lt;a href="https://schemity.com/doc/json-storage-format/" rel="noopener noreferrer"&gt;a plain JSON file&lt;/a&gt; in a workspace folder you choose. Put the folder in the application repository and the schema design is &lt;a href="https://schemity.com/doc/version-control-git/" rel="noopener noreferrer"&gt;versioned with the code it describes&lt;/a&gt;: the same branch, the same history, blame and revert. Nobody reviews a schema change by reading that JSON, though: colours, positions and sizes change with every edit and would bury the one column that matters. A reviewer opens the pull request's migration file in Schemity's change preview and sees it drawn on their own diagram, with its impact, or opens the updated diagram against a database the migration has not reached yet and sees the same preview there.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Move a Navicat Model to Schemity?
&lt;/h2&gt;

&lt;p&gt;Open the &lt;code&gt;.nmodel&lt;/code&gt; file with Import Navicat on a new diagram's empty canvas, and the chosen diagram's tables arrive with their keys, indexes, check constraints, comments and positions.&lt;/p&gt;

&lt;p&gt;If the file holds several diagrams, you pick one; when they span several databases, each is labelled with its own (a single &lt;code&gt;.nmodel&lt;/code&gt; can hold a MySQL model next to a PostgreSQL one). Import Navicat reads MySQL, MariaDB, PostgreSQL, SQLite and SQL Server models, and column types become your diagram's database's own, so a MySQL model can start a PostgreSQL design; checks, defaults and indexes that do not carry over to the new database are counted in the drawer. What Schemity does not draw, the model's views, triggers, functions and sequences, is left out and counted in the drawer, so you know what did not come across.&lt;/p&gt;

&lt;p&gt;Two cases it does not cover, stated plainly. Navicat &lt;strong&gt;Oracle models&lt;/strong&gt; are refused by name, because Schemity has no Oracle dialect. And the older &lt;strong&gt;&lt;code&gt;.ndm2&lt;/code&gt;&lt;/strong&gt; files of Data Modeler 3 and 4 use a different layout that the import does not read. For either, export the model to SQL in Navicat (an Enterprise feature) and use Import SQL, or connect Schemity to the database the model describes and read it directly. The same canvas also opens &lt;a href="https://schemity.com/blog/switching-erd-tools-shouldnt-mean-starting-over/" rel="noopener noreferrer"&gt;the other model formats Schemity imports&lt;/a&gt;: DBML, Visual Paradigm, MySQL Workbench and DbSchema.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does AI Work in Navicat Data Modeler and in Schemity?
&lt;/h2&gt;

&lt;p&gt;Navicat runs an AI assistant inside the modeler on a provider key you supply, in Enterprise only; Schemity runs no model and gives the agent you already use a local MCP server instead.&lt;/p&gt;

&lt;p&gt;Navicat Data Modeler 5 can design tables, optimize them, fix views and fill comments with AI. Its &lt;a href="https://www.navicat.com/manual/online_manual/en/navicat_data_modeler_5/win_manual/model_view_fix_ai.html" rel="noopener noreferrer"&gt;manual&lt;/a&gt; says the assistant "relies on external AI providers", so you bring an account and an API key: OpenAI, Gemini, Claude, Grok, DeepSeek and others, or a local model through Ollama or LM Studio. With Ollama or LM Studio the model runs locally, which is a fair answer to the privacy question.&lt;/p&gt;

&lt;p&gt;Schemity takes the other route. It is a &lt;a href="https://schemity.com/doc/ai-assisted-design/" rel="noopener noreferrer"&gt;local database MCP server&lt;/a&gt; with 18 tools for Claude Code, Claude Desktop, Cursor, Codex, OpenCode or any MCP host: the agent can read the schema, analyse a migration file it has written, and propose changes that land on your diagram as unsaved edits for you to review. The agent never gets the database credentials, and nothing it does writes to a database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Navicat Data Modeler vs Schemity
&lt;/h2&gt;

&lt;p&gt;Navicat Data Modeler is broad data modeling software whose database round trip starts at the US$749 Enterprise edition, and Schemity is database design software that reads the database and previews each schema change against it, for $129 once.&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;Navicat Data Modeler 5&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;Data modeling software: conceptual, logical, physical and hybrid models&lt;/td&gt;
&lt;td&gt;Database design software, desktop app (macOS, Windows, Linux)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reads a live database&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Enterprise edition (US$749, or US$379.99/yr)&lt;/td&gt;
&lt;td&gt;Every license&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, MongoDB, MariaDB, SQL Server, Oracle, SQLite, Snowflake, plus cloud services such as Redshift&lt;/td&gt;
&lt;td&gt;PostgreSQL (Supabase, CockroachDB), MySQL, MariaDB, SQL Server, SQLite (and Cloudflare D1), Snowflake&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;Synchronize database to model, run on demand&lt;/td&gt;
&lt;td&gt;Read again on every open and on Refresh, layout kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Changing the database&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Synchronize model to database: DDL comparison and a deployment script&lt;/td&gt;
&lt;td&gt;Migration SQL plus impact analysis against the live database, applied only on approval (Snowflake and Cloudflare D1 diagrams are read-only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Model levels&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Conceptual, logical, physical and hybrid; relational, dimensional and Data Vault 2.0&lt;/td&gt;
&lt;td&gt;Physical, the schema the database runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Notation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Crow's Foot, IDEF1X and UML&lt;/td&gt;
&lt;td&gt;Crow's foot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Where models live&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One binary model file; sharing through Navicat Cloud or On-Prem Server&lt;/td&gt;
&lt;td&gt;JSON files in a folder you choose, ready for Git&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;Built-in assistant on your own provider key, Enterprise only&lt;/td&gt;
&lt;td&gt;Local MCP server with 18 tools for the agent you already use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Free use&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lite edition, drawing only; 14-day trial of Enterprise&lt;/td&gt;
&lt;td&gt;Two-week trial with everything; after it, design-only diagrams with PNG, JPEG and JSON export&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Price for database work&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;US$749 plus US$119.84 for a second year, or US$379.99/yr&lt;/td&gt;
&lt;td&gt;$129 once, optional $29/yr for updates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where Navicat Data Modeler Wins
&lt;/h2&gt;

&lt;p&gt;Navicat Data Modeler is the better choice when you model Oracle, MongoDB or another database Schemity does not connect to, when you need conceptual, logical, dimensional or Data Vault models, or when your team already shares models through Navicat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More databases.&lt;/strong&gt; Navicat models MongoDB, Oracle, OceanBase, TiDB and several more alongside the relational databases Schemity covers. Schemity connects to PostgreSQL (and Supabase and CockroachDB), MySQL, MariaDB, SQL Server, SQLite (and Cloudflare D1, read over its HTTP API with an account ID and API token) and Snowflake and goes deep on them; it will not follow you to MongoDB or Oracle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More model levels.&lt;/strong&gt; Navicat keeps conceptual, logical and physical models, converts between them, adds a hybrid model, and supports dimensional and Data Vault 2.0 methods. Schemity models the physical schema only, the one the database actually runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Governance features.&lt;/strong&gt; Data Modeler 5 added naming standards and a glossary you can load from CSV, plus bulk schema editing. Schemity has &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;schema lint&lt;/a&gt; for the schema itself, but no naming-standards dictionary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More notations.&lt;/strong&gt; IDEF1X and UML are a setting per diagram in Navicat. Schemity draws crow's foot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A free edition for a small team.&lt;/strong&gt; Lite is free for up to five users per organization, with Navicat's collaboration projects. Schemity's free use after the trial is design-only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Already in the family.&lt;/strong&gt; If your team owns Navicat Premium Enterprise, the modeler is already in your license.&lt;/p&gt;

&lt;p&gt;If the physical schema is the job and the database already exists, compare the other alternatives too: the &lt;a href="https://schemity.com/blog/schemity-vs-dbschema/" rel="noopener noreferrer"&gt;DbSchema alternative&lt;/a&gt;, for a desktop database IDE that covers even more engines, the &lt;a href="https://schemity.com/blog/mysql-workbench-alternative/" rel="noopener noreferrer"&gt;MySQL Workbench alternative&lt;/a&gt; for MySQL shops, and the &lt;a href="https://schemity.com/blog/visual-paradigm-alternative/" rel="noopener noreferrer"&gt;Visual Paradigm alternative&lt;/a&gt; for a modeling suite with a similar edition split.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>tooling</category>
      <category>programming</category>
    </item>
    <item>
      <title>Visual Paradigm Alternative: Database Design Software Without the Modeling Suite</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Tue, 03 Nov 2026 10:54:21 +0000</pubDate>
      <link>https://dev.to/tbson87/visual-paradigm-alternative-database-design-software-without-the-modeling-suite-2bo5</link>
      <guid>https://dev.to/tbson87/visual-paradigm-alternative-database-design-software-without-the-modeling-suite-2bo5</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; Visual Paradigm draws ERDs in every edition, but its edition matrix keeps reverse engineering from a database, the database generation wizard and database patching for Standard (US$349 perpetual, or US$19 a month) and up, with updates after the first year at US$100 a year. Schemity is database design software and nothing else: it reads PostgreSQL, MySQL, SQL Server, SQLite or Snowflake, shows what a schema change will do to the real database before it runs, keeps each diagram as a JSON file in Git, and gives an AI agent a local MCP server, for $129 once.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Visual Paradigm is a modeling suite in which the ERD is one diagram type among dozens, and the Visual Paradigm alternative worth considering depends on whether you use the rest. If your team models UML, SysML and BPMN in the same project, keep it. If the ERD is the only diagram you open, you are paying for a suite and still need the US$349 Standard edition before it will read your database. Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git.&lt;/p&gt;

&lt;p&gt;This page compares the two as of 3 November 2026, from Visual Paradigm's own shop, edition comparison and user guide: what each edition can do with a real database, what it costs over three years, where the diagrams live, how AI fits in, and where Visual Paradigm is the better choice.&lt;/p&gt;

&lt;p&gt;This is a Visual Paradigm ERD of a real schema, a staff skills and evaluation system of nearly 50 tables that we designed in Visual Paradigm for a project that has since been retired:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdqdvjrmlz5qovfda9uvf.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdqdvjrmlz5qovfda9uvf.webp" alt="A Visual Paradigm ERD of nearly 50 tables in orange entity boxes, among them staffs, members, positions, title_levels, skills, skill_levels, surveys, campaigns and quotas; each column shows a key icon for primary and foreign keys, its type such as int4, varchar(255), timestamp or int[], and red U and N markers for unique and nullable columns; relationships are drawn as crow's foot lines, solid or dashed, routed at right angles, with free-text notes on the canvas such as STARTING POINT OF CCF MODULE" width="800" height="490"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And this is the same project in Schemity, read from the database it ended up with: about 60 tables, Django's own auth tables and a notification module included, grouped by module into eight colored legends:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fschemity.com%2Fimages%2Fblog%2Fvisual-paradigm-alternative%2Fschemity-same-project.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fschemity.com%2Fimages%2Fblog%2Fvisual-paradigm-alternative%2Fschemity-same-project.webp" alt="Schemity's ERD of the same project's database in the dark theme: about 60 tables grouped into eight colored legends, Account with staffs, Company Board with positions and title_levels, KPI with quotas, assignments and campaigns, CCF with the skills and evaluation tables, Feedback with the survey tables, Notification, Assist &amp;amp; Config, and Django Auth &amp;amp; System with auth_user, auth_group and django_migrations; every column shows its type, such as INTEGER, VARCHAR(255), TIMESTAMPTZ, JSONB or INTEGER[], with keys marked auto, and relationships run between the legends at right angles" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two do not match table for table, and that is the point. The Visual Paradigm diagram is the design as it was last updated by hand; the database kept changing after that, and the diagram fell behind it. It often felt built for a different kind of modeling than ours, which, as a UML suite, it is. That gap, and the smaller frustrations further down, are what years of Visual Paradigm taught us, and why we built Schemity.&lt;/p&gt;

&lt;p&gt;Both tools draw a schema well. The questions below are about everything around the drawing: getting a real database into it, keeping it current, and what it costs to do either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Visual Paradigm Edition Can Reverse Engineer a Database?
&lt;/h2&gt;

&lt;p&gt;Only Standard, Professional and Enterprise: Modeler and the free Community edition draw ERDs but cannot read a database or patch one; Modeler can only write create and drop scripts for the entities you select.&lt;/p&gt;

&lt;p&gt;Visual Paradigm's &lt;a href="https://www.visual-paradigm.com/support/edition-comparison.jsp" rel="noopener noreferrer"&gt;edition comparison&lt;/a&gt; splits database work into two groups. The &lt;strong&gt;Database Design Toolset&lt;/strong&gt;, which every edition has, covers drawing: entity relationship diagrams in crow's foot and Chen notation, conceptual, logical and physical models, stored procedure and trigger shapes, sample records, and ERwin import. The &lt;strong&gt;Database Engineering&lt;/strong&gt; group is where a diagram meets a real database, and every row in it is marked for Enterprise, Professional and Standard only:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database Engineering row&lt;/th&gt;
&lt;th&gt;Enterprise&lt;/th&gt;
&lt;th&gt;Professional&lt;/th&gt;
&lt;th&gt;Standard&lt;/th&gt;
&lt;th&gt;Modeler&lt;/th&gt;
&lt;th&gt;Community&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reverse engineer ERD from database&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reverse engineer ERD from DDL&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generate DDL from ERD&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generate database from ERD&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patch database from ERD&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the US$99 Modeler edition is a drawing tool for databases: you can model a schema in it, but you cannot point it at the database you already have, and the matrix marks the generation wizard Standard and up.&lt;/p&gt;

&lt;p&gt;We checked in Visual Paradigm Modeler 17.2 (build 20260424) on 3 November 2026. Its Tools &amp;gt; DB menu does list Generate Database, Reverse Database and Reverse DDL, and each one opens the same dialog instead of running:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6pgpj0qfbowpxigc7njb.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6pgpj0qfbowpxigc7njb.webp" alt="Visual Paradigm Modeler's Non-Supported Feature dialog, reading This feature is available in Visual Paradigm Standard. Do you want to switch to it?, with the options Yes (Evaluate Visual Paradigm Enterprise), Yes (Have License), No, and No and disable all non-supported features (restart required)" width="799" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Only Database Configuration opens, so you can set up a connection to PostgreSQL or Snowflake in Modeler, but not use it to read or write a schema.&lt;/p&gt;

&lt;p&gt;Modeler does write SQL for the entities you select. Select one or several and Generate SQL... from the right-click menu opens Visual Paradigm's Ad-Hoc SQL generator, with a &lt;code&gt;CREATE TABLE&lt;/code&gt; for each, the foreign keys between them as &lt;code&gt;ALTER TABLE ... ADD CONSTRAINT&lt;/code&gt;, and the matching &lt;code&gt;DROP&lt;/code&gt; statements:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fle35s22vx2hl05eyj72u.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fle35s22vx2hl05eyj72u.webp" alt="Visual Paradigm Modeler's Generate SQL dialog for three selected entities: Database PostgreSQL, Delimiter ;, Case Upper, Include Comment and Auto Quote Default Value ticked; the DDL tab's Create box holds CREATE TABLE evaluate_methods, CREATE TABLE evaluate_methods_skills and CREATE TABLE skill_categories with SERIAL keys, then ALTER TABLE evaluate_methods_skills ADD CONSTRAINT FKevaluate_m521...; the Drop box holds the matching DROP CONSTRAINT and three DROP TABLE IF EXISTS ... CASCADE statements; the Alter section is collapsed" width="800" height="843"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is a script for a new, empty database. With nothing to compare the model against, it cannot say what to &lt;code&gt;ALTER&lt;/code&gt; in a database that already exists, which is what a migration needs; comparing the model with a database is the Standard edition's Database Engineering. Two more design features also start at Standard: the Database View editor, and synchronization between the conceptual, logical and physical models.&lt;/p&gt;

&lt;p&gt;Schemity sells one license with every feature. It &lt;a href="https://schemity.com/doc/connections-overview/" rel="noopener noreferrer"&gt;connects to a database&lt;/a&gt;, directly or, for server databases, over an SSH tunnel, and &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;reverse engineers the schema&lt;/a&gt;: tables, columns, types, defaults, &lt;code&gt;CHECK&lt;/code&gt; constraints, unique constraints, indexes, views, and every foreign key as a relationship with its &lt;code&gt;ON DELETE&lt;/code&gt; and &lt;code&gt;ON UPDATE&lt;/code&gt; actions. It reads PostgreSQL (Supabase and CockroachDB included), MySQL and MariaDB, SQL Server, SQLite and Snowflake, and with no database at all, a design-only diagram still writes &lt;code&gt;CREATE TABLE&lt;/code&gt; SQL for any of the five dialects.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqjir2qttpvlo1yz20kju.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqjir2qttpvlo1yz20kju.webp" alt="Schemity's ERD of a PostgreSQL 18.3 shop database as read from the live database: products, customers, orders and order_items with their types, identity keys marked auto, NOW() defaults, U badges on products.sku and customers.email, the composite primary key of order_items, and orders.status defaulting to pending, underlined as one of its CHECK values; three crow's foot relationships, and the view order_totals in italics with its footer reading view; the status bar reads Entities: 5, Relations: 3, PostgreSQL: 18.3" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing in that diagram was typed: every table, type, default, key and the view came from the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does Visual Paradigm Cost for ERD Work in 2026?
&lt;/h2&gt;

&lt;p&gt;Working against a real database costs US$349 perpetual for Standard, plus US$100 a year if you want updates after the first year, or US$19 a month as a subscription.&lt;/p&gt;

&lt;p&gt;Verified on Visual Paradigm's &lt;a href="https://www.visual-paradigm.com/shop/" rel="noopener noreferrer"&gt;shop&lt;/a&gt; and &lt;a href="https://www.visual-paradigm.com/shop/vp.jsp" rel="noopener noreferrer"&gt;subscription page&lt;/a&gt; on 3 November 2026, for one user:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Edition&lt;/th&gt;
&lt;th&gt;Perpetual, single seat&lt;/th&gt;
&lt;th&gt;Maintenance per extra year&lt;/th&gt;
&lt;th&gt;Subscription&lt;/th&gt;
&lt;th&gt;Reads a database&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Community&lt;/td&gt;
&lt;td&gt;Free, "for non-commercial use only"&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modeler&lt;/td&gt;
&lt;td&gt;US$99&lt;/td&gt;
&lt;td&gt;US$30&lt;/td&gt;
&lt;td&gt;US$6/mo&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard&lt;/td&gt;
&lt;td&gt;US$349&lt;/td&gt;
&lt;td&gt;US$100&lt;/td&gt;
&lt;td&gt;US$19/mo&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Professional&lt;/td&gt;
&lt;td&gt;US$799&lt;/td&gt;
&lt;td&gt;US$200&lt;/td&gt;
&lt;td&gt;US$39/mo, US$35 billed quarterly&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;US$1,999&lt;/td&gt;
&lt;td&gt;US$600&lt;/td&gt;
&lt;td&gt;US$99/mo, US$89 billed half-yearly&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;A perpetual license includes one year of maintenance. After that, the shop says, "you can continue using the last version of Visual Paradigm you downloaded, indefinitely", and maintenance buys the updates: Visual Paradigm ships "two to three versions per year", and reinstating maintenance that has lapsed costs more than extending it (US$150 instead of US$100 for Standard). A floating license, shared by concurrent users, costs more again (US$449 for Standard) and needs a Floating License Server.&lt;/p&gt;

&lt;p&gt;Over three years with updates throughout, for one engineer:&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;Up front&lt;/th&gt;
&lt;th&gt;Years 2 and 3&lt;/th&gt;
&lt;th&gt;Three years&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Visual Paradigm Standard, perpetual&lt;/td&gt;
&lt;td&gt;US$349&lt;/td&gt;
&lt;td&gt;2 x US$100&lt;/td&gt;
&lt;td&gt;US$549&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visual Paradigm Standard, subscription&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;36 x US$19&lt;/td&gt;
&lt;td&gt;US$684&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schemity&lt;/td&gt;
&lt;td&gt;$129&lt;/td&gt;
&lt;td&gt;2 x $29&lt;/td&gt;
&lt;td&gt;$187&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Schemity is $129 once, with a year of updates, an optional $29 a year after that, use on up to three of your machines (the same limit as a Visual Paradigm single seat), and a two-week free trial with every feature. Visual Paradigm's own 30-day trial is longer, and its Community edition is free for non-commercial use, which Schemity has no equivalent of.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Visual Paradigm Keep the ERD in Sync With the Database?
&lt;/h2&gt;

&lt;p&gt;It can reverse engineer the database again and patch the database from the diagram, but we found no step in its user guide that merges a changed database into a diagram you have already arranged.&lt;/p&gt;

&lt;p&gt;Visual Paradigm's &lt;a href="https://www.visual-paradigm.com/support/documents/vpuserguide/3563/3587/85406_reverseengin.html" rel="noopener noreferrer"&gt;reverse engineering&lt;/a&gt; offers to "Form a new diagram with the reversed entities" or add them to the first ERD in the project, and warns that "having too many entities on one diagram may result in a poor database design that is hard to read and maintain." Going the other way, its &lt;a href="https://www.visual-paradigm.com/support/documents/vpuserguide/3563/3587/85408_patchingdesi.html" rel="noopener noreferrer"&gt;database patching&lt;/a&gt; is a real and useful feature: "We compare the differences between your design and a database, and patch the differences to your database accordingly." You can have it write the DDL to a file to review before anything runs, and an option will "drop tables and/or columns that do not exist in your ER diagram."&lt;/p&gt;

&lt;p&gt;What the patch flow shows you is SQL. Reading that SQL is where the expensive questions live: how many rows a dropped column takes with it, whether a statement will fail on rows already in the table, which views read the column, and whether the change locks or rewrites a large table. None of that is in the statement.&lt;/p&gt;

&lt;p&gt;In Schemity, a connected diagram is read again from the database every time you open it, and &lt;a href="https://schemity.com/doc/resync-database/" rel="noopener noreferrer"&gt;Refresh&lt;/a&gt; re-reads it on demand after a migration ran elsewhere: tables you placed keep their position, dropped ones disappear, and new ones arrive placed clear of the rest. A change you draw becomes a plan against the real database, and &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; prices it before it runs: the rows a drop would take, the statements that would fail on existing data, the dependent views that would block or break, and the statements that would rewrite or lock a large table. &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;Lint&lt;/a&gt; adds what is wrong in the schema itself, such as a foreign key with no index or a type that does not match the key it references. Nothing is applied until you approve the &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL&lt;/a&gt; (a Snowflake diagram is never migrated at all), and a migration file written by an ORM or an AI agent can be analysed the same way without being run.&lt;/p&gt;

&lt;p&gt;Delete &lt;code&gt;orders.placed_at&lt;/code&gt; from the shop diagram above and open Preview changes:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvhzcv5zwsl1erq0dsc4w.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvhzcv5zwsl1erq0dsc4w.webp" alt="Schemity's Preview changes view after orders.placed_at was deleted: the orders table with placed_at highlighted in red; the Findings drawer lists the planned migration ALTER TABLE &amp;quot;orders&amp;quot; DROP COLUMN IF EXISTS &amp;quot;placed_at&amp;quot;, one planned change Column orders.placed_at dropped, Lint 0, and two impact findings: Drops orders.placed_at: ~200K rows, and 1 object depends on orders.placed_at and the database refuses the change, the view order_totals" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The statement at the top is all a patch script would show. The findings under it are the two facts that decide whether to run it: the column holds about 200,000 values, and PostgreSQL would refuse the statement until the view &lt;code&gt;order_totals&lt;/code&gt; stops reading it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Did We Run Into Drawing a Real Schema in Visual Paradigm?
&lt;/h2&gt;

&lt;p&gt;Seven smaller things, all in drawing the schema itself: creating a relationship, self-referencing relationships, PostgreSQL arrays, unique constraint names, composite unique markers, allowed values, and copy and paste.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Creating a relationship.&lt;/strong&gt; Drag a relationship out from one table and Visual Paradigm opens a picker with two rows of the same four relationship symbols, in a different order and with no labels; the top row may be the recently used ones, but nothing on screen says so:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbeuntvy7gz41ketkwjb2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbeuntvy7gz41ketkwjb2.webp" alt="Visual Paradigm's relationship picker after dragging out from skill_source_questions: a top row of four crow's foot relationship symbols and, below it, a boxed row of the same four symbols in a different order, none of them labelled" width="800" height="829"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Drop it on the foreign key column of the other table instead, and a second picker appears, this time with four symbols on top and five below, each paired with an entity icon:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgi6fep40lwond5wvu21m.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgi6fep40lwond5wvu21m.webp" alt="Visual Paradigm's second picker after dropping a relationship on skill_source_answers: a top row of four relationship symbols each paired with an entity icon and a boxed row of five more, also unlabelled" width="800" height="975"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Schemity you drag from the parent table onto the child, and Schemity adds the foreign key column to the child, pointing at the parent's primary key. The relation dialog then opens with both tables and the new column filled in, and asks its questions in words: a relation or a virtual one, &lt;code&gt;1:N&lt;/code&gt;, &lt;code&gt;1:1&lt;/code&gt; or &lt;code&gt;N:N&lt;/code&gt; (which builds the junction table), and the &lt;code&gt;ON DELETE&lt;/code&gt; and &lt;code&gt;ON UPDATE&lt;/code&gt; actions. Dropping on empty canvas lets you pick the target table by name instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-referencing relationships.&lt;/strong&gt; Visual Paradigm would not let us draw a self-referencing relationship by dragging a relationship from a table back onto itself, which is how &lt;code&gt;positions.parent_id&lt;/code&gt; points at its own table. There may be another way to make one; we did not find it, and its guide to drawing entities does not cover the case. In Schemity you drag the new-relation handle off a table and drop it back on the same table, and the self-reference is drawn, as it is on &lt;code&gt;positions&lt;/code&gt; above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL arrays.&lt;/strong&gt; We found no array type in Visual Paradigm's PostgreSQL type list, so to make a column such as &lt;code&gt;evaluate_campaigns.attended_titles&lt;/code&gt; show as an array on the canvas, we gave it the base type &lt;code&gt;int4&lt;/code&gt; and typed &lt;code&gt;int[]&lt;/code&gt; into its User type field:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7758bn45lb8u3rjuchl6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7758bn45lb8u3rjuchl6.webp" alt="Visual Paradigm's Column Specification dialog for the column attended_titles: Domain Unspecified, Type int4, Length 0, User type int[], an empty Default value and Check constraint, and Nullable, Unique and Index unticked" width="800" height="1104"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The canvas then reads &lt;code&gt;int[]&lt;/code&gt;, but the type itself is still &lt;code&gt;int4&lt;/code&gt; with a label beside it. In Schemity, a PostgreSQL column has an array selector next to its type, so &lt;code&gt;attended_titles&lt;/code&gt; arrives from the database above as &lt;code&gt;INTEGER[]&lt;/code&gt;, is written back as one, and is what lint checks when an array holds foreign keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unique constraint names.&lt;/strong&gt; Visual Paradigm will not save a unique constraint without a name you type yourself: leave Name empty, as here for &lt;code&gt;(hard_level, group)&lt;/code&gt; on &lt;code&gt;skill_source_questions&lt;/code&gt;, and it stops with "name cannot be empty":&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fma0ffiurwwwhsdjgn5a8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fma0ffiurwwwhsdjgn5a8.webp" alt="Visual Paradigm's Unique Constraint Specification dialog with an empty Name field and the columns hard_level and group selected, covered by an Error dialog reading Please correct the following errors: name cannot be empty" width="800" height="883"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which columns are unique together.&lt;/strong&gt; Once the constraint is saved, the canvas does not say which columns are unique together. On &lt;code&gt;skill_source_questions&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt; is unique on its own and &lt;code&gt;hard_level&lt;/code&gt; and &lt;code&gt;group&lt;/code&gt; are unique as a pair, yet all three carry the same mark:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj9xckkj9ft6tfaan6t4f.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj9xckkj9ft6tfaan6t4f.webp" alt="Visual Paradigm's ERD entity skill_source_questions with columns id, skill_id, skill_source_question_type_id, title, hard_level, group, priority and result_ref; title, hard_level and group each carry an identical red U marker, and result_ref an N marker" width="654" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;members&lt;/code&gt; is harder still. Three unique constraints share &lt;code&gt;tenant_id&lt;/code&gt;: &lt;code&gt;(tenant_id, user_id)&lt;/code&gt;, &lt;code&gt;(tenant_id, sso_sub)&lt;/code&gt; and &lt;code&gt;(tenant_id, sso_id)&lt;/code&gt;. Only the Constraints tab of the Entity Specification says so; the canvas gives &lt;code&gt;tenant_id&lt;/code&gt; one &lt;code&gt;U&lt;/code&gt; and marks &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;sso_sub&lt;/code&gt; and &lt;code&gt;sso_id&lt;/code&gt; as if each were unique on its own:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv3wjc88rmw9pabx3onbw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv3wjc88rmw9pabx3onbw.webp" alt="Visual Paradigm's members entity beside its Entity Specification dialog on the Constraints tab: the entity shows a single U on tenant_id and one U each on user_id, sso_sub and sso_id, while the Constraints tab lists three unique constraints, tenant_user_key UNIQUE (tenant_id, user_id), tenant_sso_sub_key UNIQUE (tenant_id, sso_sub) and tenant_sso_id_key UNIQUE (tenant_id, sso_id)" width="800" height="255"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Schemity names a unique constraint for you when you leave the name empty, in PostgreSQL's own pattern: &lt;code&gt;skill_source_questions_hard_level_group_key&lt;/code&gt;. On the canvas, a single-column unique is a plain &lt;code&gt;U&lt;/code&gt; and a composite unique is an underlined &lt;code&gt;U&lt;/code&gt;; a column in several unique constraints gets one &lt;code&gt;U&lt;/code&gt; for each, and each constraint can take its own color. So &lt;code&gt;(hard_level, group)&lt;/code&gt; reads as one rule and &lt;code&gt;title&lt;/code&gt; as another, and on &lt;code&gt;members&lt;/code&gt;, &lt;code&gt;tenant_id&lt;/code&gt; carries three underlined &lt;code&gt;U&lt;/code&gt; marks, one per constraint; give each constraint its own color and the &lt;code&gt;U&lt;/code&gt; on its partner column matches.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2rv3gsuvemeht1qof9l.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2rv3gsuvemeht1qof9l.webp" alt="Schemity's ERD of the same two tables: on skill_source_questions, title has a plain U while hard_level and group each have an underlined U in the same pink; on members, tenant_id carries three underlined U marks in lavender, pink and gold, matched by one underlined U on sso_id in lavender, on sso_sub in pink and on user_id in gold, with N markers on the nullable sso_id, sso_sub and result_ref; members.status and its default ACTIVE are both underlined, and the members footer reads field: 7, u: 3, cc: 1" width="799" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allowed values.&lt;/strong&gt; &lt;code&gt;members.status&lt;/code&gt; takes one of a fixed set of values, &lt;code&gt;ACTIVE&lt;/code&gt; among them, but the entity has no place for that set, so the column reads &lt;code&gt;text = ACTIVE&lt;/code&gt; and nothing more. Its Column Specification does have a Check constraint field that takes the rule as text, but the values never show on the diagram, so we kept them in a sticky note or the column's description, apart from the DDL they belong to:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5w2hztktpgwbpk0sqirc.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5w2hztktpgwbpk0sqirc.webp" alt="Visual Paradigm's ERD entity members: id int8, tenant_id and user_id int8 marked U, then text columns avatar, sso_sub, sso_id, status, lang_code, pwd, access_token and refresh_token with defaults such as = &amp;quot;&amp;quot;, = NULL, = ACTIVE and = EN, N and U markers on sso_sub and sso_id, and activated_at, created_at and updated_at timestamps" width="570" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Schemity the values are part of the column. A &lt;code&gt;CHECK (status IN (...))&lt;/code&gt; list or a PostgreSQL enum type is read from the database with the column; a column that has one is underlined on the canvas, and so is a default that is one of its values, as &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;ACTIVE&lt;/code&gt; are on the same &lt;code&gt;members&lt;/code&gt; table in Schemity above. Open the field and its Default control offers the values the &lt;code&gt;CHECK&lt;/code&gt; allows, &lt;code&gt;ACTIVE&lt;/code&gt; and &lt;code&gt;INACTIVE&lt;/code&gt;, because they are part of the column, not a note beside it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkl9wg7etu7316db2n79h.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkl9wg7etu7316db2n79h.webp" alt="Schemity's field drawer for members.status: Field status with type TEXT, an empty Description, PK, Unique and Nullable unticked, and the Default control open on a list reading No default, ACTIVE (selected) and INACTIVE, the two values its CHECK constraint allows; on the canvas behind it, status and ACTIVE are underlined and the footer reads cc: 1" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;View options (Cmd+O, or Ctrl+O on Windows and Linux) lists the values without opening the field editor at all:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhar2tl21fh9mpi1aqv6v.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhar2tl21fh9mpi1aqv6v.webp" alt="Schemity's Options dialog for members.status, titled Options · members.status, reading CHECK constraint values (2) above two tags, ACTIVE and INACTIVE, over the members and skill_source_questions tables on the canvas" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And lint flags a &lt;code&gt;status&lt;/code&gt;-like column with nothing restricting its values, so the set that Visual Paradigm left to a sticky note is something Schemity asks you for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Copy and paste.&lt;/strong&gt; Copy an entity and paste it with Ctrl-V, and Visual Paradigm does not make a copy: it pastes a second view of the same entity, marked &lt;code&gt;a&lt;/code&gt; for auxiliary view, while the original is marked &lt;code&gt;M&lt;/code&gt; for master view. The two shapes are one table, so editing either one edits both:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fukm9k3jo55sk6ir7hxoe.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fukm9k3jo55sk6ir7hxoe.webp" alt="Two Visual Paradigm shapes both named skill_source_answers with identical columns id, skill_source_question_id, title marked U and correct, the upper one marked M in its corner and the lower one marked a" width="738" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is deliberate. Visual Paradigm's &lt;a href="https://www.visual-paradigm.com/support/documents/vpuserguide/1283/28/6051_copyandpaste.html" rel="noopener noreferrer"&gt;copy and paste guide&lt;/a&gt; says pasting as view presents "the same model element in another view", while Paste Model from the right-click menu, or Duplicate, makes a real copy, and its Know-how site explains how to bind Ctrl-V to Paste Model instead. It makes sense for a model shown on many diagrams. In a database diagram, where each shape is one &lt;code&gt;CREATE TABLE&lt;/code&gt;, it is the opposite of what Ctrl-V does everywhere else. In Schemity, pasting &lt;code&gt;skill_source_answers&lt;/code&gt; creates &lt;code&gt;skill_source_answers_copy&lt;/code&gt;, a separate table that the migration creates on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Do Visual Paradigm and Schemity Keep the Diagram?
&lt;/h2&gt;

&lt;p&gt;Visual Paradigm keeps a project in one &lt;code&gt;.vpp&lt;/code&gt; file and versions it through its own repository; Schemity keeps each diagram as a JSON file you commit to Git like code.&lt;/p&gt;

&lt;p&gt;Visual Paradigm "saves all project content to a single file, with file extension .vpp", and its user guide names "Visual Paradigm VPository and VP Teamwork Server" as "the supported standards of versioning systems", with commit, update and merge run from Visual Paradigm's own Teamwork Client. That is a complete system for a team that adopts it, and a separate one from the repository the application code lives in.&lt;/p&gt;

&lt;p&gt;A Schemity diagram is &lt;a href="https://schemity.com/doc/json-storage-format/" rel="noopener noreferrer"&gt;a plain JSON file&lt;/a&gt; in a workspace folder you choose. Put the folder in the application repository and the schema design is &lt;a href="https://schemity.com/doc/version-control-git/" rel="noopener noreferrer"&gt;versioned with the code it describes&lt;/a&gt;: the same branch, the same pull request, the same history. A reviewer does not need to read JSON to review a schema change either; they open the pull request's migration against their own connected diagram and see it drawn and priced in the change preview.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does AI Work in Visual Paradigm and in Schemity?
&lt;/h2&gt;

&lt;p&gt;Visual Paradigm's AI ERD generation runs through its cloud service on the Professional and Enterprise editions with active maintenance; Schemity gives the AI agent you already use a local MCP server.&lt;/p&gt;

&lt;p&gt;Visual Paradigm's AI Diagram Generator can turn a description of business objects into an ERD. Its shop marks AI diagram generation for Enterprise and Professional only, groups the AI apps as "via VP Online", and states that "active maintenance is required to continue using any AI-powered features." A &lt;a href="https://www.visual-paradigm.com/features/custom-ai-endpoint/" rel="noopener noreferrer"&gt;custom AI endpoint&lt;/a&gt; lets a workspace manager bring an API key for providers such as OpenAI, Anthropic or Google; for "an internally hosted model", the page points to its enterprise team. We did not find an MCP server in Visual Paradigm's documentation as of November 2026.&lt;/p&gt;

&lt;p&gt;Schemity does not run a model of its own. It is a &lt;a href="https://schemity.com/doc/ai-assisted-design/" rel="noopener noreferrer"&gt;local database MCP server&lt;/a&gt; with 18 tools for Claude Code, Claude Desktop, Cursor, Codex, OpenCode or any MCP host: the agent can read the schema and its context views, analyse a migration file it wrote, run lint, and propose changes, which land on your open diagram as unsaved edits for you to review and save. Schemity itself sends your schema nowhere; what reaches a model is whatever your agent's host sends, under the agreement you already have with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visual Paradigm vs Schemity
&lt;/h2&gt;

&lt;p&gt;Visual Paradigm is a modeling suite whose database engineering starts at the US$349 Standard edition, and Schemity is database design software that reads the database and previews each schema change against it, for $129 once.&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;Visual Paradigm&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;Modeling suite: UML, SysML, BPMN, ERD and more in one project&lt;/td&gt;
&lt;td&gt;Database design software, desktop app (macOS, Windows, Linux)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reads a live database&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Standard edition and up (US$349)&lt;/td&gt;
&lt;td&gt;Every license&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, SQL Server, Oracle, PostgreSQL, SQLite, DB2, Sybase, MariaDB, Informix, Firebird and more&lt;/td&gt;
&lt;td&gt;PostgreSQL (Supabase, CockroachDB), MySQL, MariaDB, SQL Server, SQLite, Snowflake&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;Reverse again, into a new ERD, the first ERD, or an entity list you drag onto diagrams by hand&lt;/td&gt;
&lt;td&gt;Read again on every open and on Refresh, layout kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Changing the database&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Patch: compares the ERD with the database and writes the &lt;code&gt;ALTER&lt;/code&gt; and &lt;code&gt;CREATE&lt;/code&gt; statements, to the database or to a file to review first&lt;/td&gt;
&lt;td&gt;Migration SQL plus lint and impact analysis against the live database, applied only on approval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Model levels&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Conceptual, logical and physical, synced from Standard up&lt;/td&gt;
&lt;td&gt;Physical, the schema the database runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Notation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Crow's foot and Chen&lt;/td&gt;
&lt;td&gt;Crow's foot&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;One &lt;code&gt;.vpp&lt;/code&gt; file per project; versioning through VPository or Teamwork Server&lt;/td&gt;
&lt;td&gt;JSON files in a folder you choose, ready for Git&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Large schemas&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Several ERDs per project, plus sub-diagrams per model element&lt;/td&gt;
&lt;td&gt;Context views per domain and a Context Map of the dependencies between them&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;AI ERD generation via VP Online, Professional and up, with active maintenance&lt;/td&gt;
&lt;td&gt;Local MCP server with 18 tools for the agent you already use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Footprint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;About 790 MB download; 4 GB RAM minimum, 8 GB recommended&lt;/td&gt;
&lt;td&gt;183 MB of memory open on its home screen, &lt;a href="https://schemity.com/blog/database-diagram-1000-tables-memory-cpu/" rel="noopener noreferrer"&gt;measured on an M1 Pro&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Free use&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Community edition and VP Online Free, non-commercial only; 30-day trial&lt;/td&gt;
&lt;td&gt;Two-week trial with everything; after it, design-only diagrams with PNG, JPEG and JSON export&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Price for database work&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;US$349 plus US$100/yr for updates, or US$19/mo&lt;/td&gt;
&lt;td&gt;$129 once, optional $29/yr for updates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where Visual Paradigm Wins
&lt;/h2&gt;

&lt;p&gt;Visual Paradigm is the better choice when the ERD is one model among many, when you need conceptual and logical models, Chen notation, stored procedures or Oracle and DB2, or when your team already works in its repository.&lt;/p&gt;

&lt;p&gt;For years Visual Paradigm was still the best tool we could find for this work, frustrations included; the others we tried were worse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One suite for every model.&lt;/strong&gt; UML, SysML, BPMN and the ERD live in one project, and from Standard up Visual Paradigm can synchronize an ERD with a UML class diagram and generate Hibernate code from it. Schemity draws databases and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conceptual and logical models.&lt;/strong&gt; Visual Paradigm models a schema at three levels and, from Standard up, keeps them in sync. Schemity models the physical schema, the one the database actually runs, and does not do a conceptual model with generalization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More of the database.&lt;/strong&gt; Stored procedures and triggers are shapes on a Visual Paradigm ERD, and its database list reaches Oracle, DB2, Informix, Sybase and Firebird. Schemity draws tables, views, keys, constraints and indexes, not procedures or triggers, though its impact analysis does name the triggers, functions and procedures a change would affect, and it connects to the databases listed above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migrating from other modeling tools.&lt;/strong&gt; Visual Paradigm imports ERwin projects in every edition and PowerDesigner models from Modeler up. Schemity imports SQL, so a model from those tools arrives as the DDL they generate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documentation and team process.&lt;/strong&gt; Doc. Composer builds formatted design documents from Standard up, and VPository gives a team commit, merge and revision history inside Visual Paradigm. Schemity exports a data dictionary and leaves versioning to Git.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A free edition.&lt;/strong&gt; Community is free for non-commercial ERD work, with no time limit. Schemity's free use after the trial is design-only.&lt;/p&gt;

&lt;p&gt;If the ERD is the only diagram you need and the database already exists, compare the other alternatives too: the &lt;a href="https://schemity.com/blog/schemity-vs-dbschema/" rel="noopener noreferrer"&gt;DbSchema alternative&lt;/a&gt;, for a desktop database IDE that covers far more database engines, the &lt;a href="https://schemity.com/blog/mysql-workbench-alternative/" rel="noopener noreferrer"&gt;MySQL Workbench alternative&lt;/a&gt;, and the &lt;a href="https://schemity.com/blog/schemity-vs-chartdb/" rel="noopener noreferrer"&gt;ChartDB alternative&lt;/a&gt; for a browser-first view.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>tooling</category>
      <category>programming</category>
    </item>
    <item>
      <title>MySQL ALTER TABLE: Which Changes Lock the Table, and How to Make One Fail Instead of Copying</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Tue, 03 Nov 2026 02:44:57 +0000</pubDate>
      <link>https://dev.to/tbson87/mysql-alter-table-which-changes-lock-the-table-and-how-to-make-one-fail-instead-of-copying-4e13</link>
      <guid>https://dev.to/tbson87/mysql-alter-table-which-changes-lock-the-table-and-how-to-make-one-fail-instead-of-copying-4e13</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; On MySQL 8.4, adding, dropping or renaming a column and changing a default are metadata-only, and an index or &lt;code&gt;NOT NULL&lt;/code&gt; builds while writes continue. Other type changes, including a &lt;code&gt;VARCHAR&lt;/code&gt; that crosses 255 bytes, a &lt;code&gt;CHECK&lt;/code&gt; and a foreign key with checks on copy the table and hold back writes until the copy ends. Every &lt;code&gt;ALTER TABLE&lt;/code&gt;, instant ones included, first waits for an exclusive metadata lock, and new queries on the table queue behind it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On MySQL 8.4, most &lt;code&gt;ALTER TABLE&lt;/code&gt; statements do not hold the table for their whole run. Adding, dropping or renaming a column and changing a default only edit metadata, and adding an index or a &lt;code&gt;NOT NULL&lt;/code&gt; builds while reads and writes continue. Among the common changes, the ones that copy the table, and hold back writes until the copy ends, are a data type change (including a &lt;code&gt;VARCHAR&lt;/code&gt; that grows past 255 bytes), a new &lt;code&gt;CHECK&lt;/code&gt; and a foreign key added with checks on. And every one of them, instant ones too, first needs an exclusive metadata lock, which is how a 0.1-second &lt;code&gt;ADD COLUMN&lt;/code&gt; can stop reads on the table for 17 seconds.&lt;/p&gt;

&lt;p&gt;Everything below ran on MySQL 8.4.11 in a throwaway Docker container on a laptop, against a &lt;code&gt;users&lt;/code&gt; table of 1,000,000 rows (&lt;code&gt;id INT AUTO_INCREMENT&lt;/code&gt;, &lt;code&gt;email VARCHAR(50) NOT NULL&lt;/code&gt;, &lt;code&gt;name VARCHAR(100) NULL&lt;/code&gt;, &lt;code&gt;status VARCHAR(20)&lt;/code&gt;, &lt;code&gt;created_at DATETIME&lt;/code&gt;, all &lt;code&gt;utf8mb4&lt;/code&gt;) and an &lt;code&gt;orders&lt;/code&gt; table of 1,000,000 rows pointing at it. Each change in the table below ran on a fresh copy of &lt;code&gt;users&lt;/code&gt; with no foreign key on it, so every row starts from the same table. Read the timings as ratios between kinds of change, not as what your server will do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does MySQL ALTER TABLE lock the table?
&lt;/h2&gt;

&lt;p&gt;Briefly, always; for the whole operation, only when MySQL decides to copy the table.&lt;/p&gt;

&lt;p&gt;When a statement names no algorithm, the &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/alter-table.html" rel="noopener noreferrer"&gt;&lt;code&gt;ALTER TABLE&lt;/code&gt; documentation&lt;/a&gt; says MySQL "uses &lt;code&gt;ALGORITHM=INSTANT&lt;/code&gt;" where it is supported, "otherwise, &lt;code&gt;ALGORITHM=INPLACE&lt;/code&gt; is used. If &lt;code&gt;ALGORITHM=INPLACE&lt;/code&gt; is not supported, &lt;code&gt;ALGORITHM=COPY&lt;/code&gt; is used." &lt;code&gt;INSTANT&lt;/code&gt; edits only the data dictionary. &lt;code&gt;INPLACE&lt;/code&gt; builds without copying rows into a new table and usually lets writes continue. &lt;code&gt;COPY&lt;/code&gt; builds a new table row by row, and while it does, reads go through, except for a moment at the final swap, and writes wait. Which one a change gets is decided by the change, not by you. The algorithm column below is the one MySQL accepted when the statement named it, since MySQL does not report which one it picked:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change on 1,000,000 rows&lt;/th&gt;
&lt;th&gt;Algorithm it runs as&lt;/th&gt;
&lt;th&gt;Writes during it&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD COLUMN phone VARCHAR(30) NULL AFTER email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INSTANT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;0.12 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DROP COLUMN name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INSTANT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;0.06 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RENAME COLUMN name TO full_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INSTANT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;0.03 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ALTER COLUMN status SET DEFAULT 'pending'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INSTANT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;0.02 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;MODIFY COLUMN name VARCHAR(200) NULL&lt;/code&gt; (was 100)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;INPLACE&lt;/code&gt;, no rebuild&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;0.02 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MODIFY COLUMN name VARCHAR(100) NOT NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;INPLACE&lt;/code&gt;, rebuilds the table&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;1.20 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD INDEX ix_name (name)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INPLACE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;1.18 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;MODIFY COLUMN email VARCHAR(100) NOT NULL&lt;/code&gt; (was 50)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COPY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Held back&lt;/td&gt;
&lt;td&gt;1.92 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MODIFY COLUMN id INT UNSIGNED NOT NULL AUTO_INCREMENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COPY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Held back&lt;/td&gt;
&lt;td&gt;1.64 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COPY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Held back&lt;/td&gt;
&lt;td&gt;1.68 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD CONSTRAINT ... CHECK (status IN (...))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COPY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Held back&lt;/td&gt;
&lt;td&gt;1.74 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ADD CONSTRAINT ... FOREIGN KEY&lt;/code&gt; on &lt;code&gt;orders&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COPY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Held back&lt;/td&gt;
&lt;td&gt;4.07 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "writes during it" column was measured, not read off the documentation. On the same table grown to 4,000,000 rows, a single-row &lt;code&gt;INSERT&lt;/code&gt; sent 1.5 seconds into the run waited 6.3 seconds for the &lt;code&gt;VARCHAR(50)&lt;/code&gt; to &lt;code&gt;VARCHAR(100)&lt;/code&gt; copy and 5.9 seconds for the &lt;code&gt;CHECK&lt;/code&gt;, while a &lt;code&gt;SELECT&lt;/code&gt; by primary key sent at the same moment came back in about 0.1 second. During the &lt;code&gt;NOT NULL&lt;/code&gt; rebuild and the index build, the same &lt;code&gt;INSERT&lt;/code&gt; came back in 0.12 and 0.22 seconds. The &lt;code&gt;NOT NULL&lt;/code&gt; row needs one more caveat: it is in-place, but with the default strict SQL mode it still fails with &lt;code&gt;ERROR 1138 (22004): Invalid use of NULL value&lt;/code&gt; if any existing row holds a &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The foreign key row is the one most often missed. The &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html" rel="noopener noreferrer"&gt;online DDL reference&lt;/a&gt; says adding one supports &lt;code&gt;INPLACE&lt;/code&gt; only "when &lt;code&gt;foreign_key_checks&lt;/code&gt; is disabled. Otherwise, only the &lt;code&gt;COPY&lt;/code&gt; algorithm is supported." With checks off, the same statement took 0.01 seconds, and it validated nothing: on a small test table, a key added that way was accepted over a child row pointing at a parent that does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does widening a VARCHAR sometimes copy the table?
&lt;/h2&gt;

&lt;p&gt;Because the copy is triggered by the column's length prefix changing from one byte to two, and MySQL counts that at the character set's widest character, 4 bytes for &lt;code&gt;utf8mb4&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;VARCHAR&lt;/code&gt; value carries a prefix saying how long it is. Up to 255 bytes it is one byte; from 256 bytes it is two. The documentation states the consequence: in-place &lt;code&gt;ALTER TABLE&lt;/code&gt; "does not support increasing the size of a &lt;code&gt;VARCHAR&lt;/code&gt; column from less than 256 bytes to a size equal to or greater than 256 bytes." At 4 bytes per character, &lt;code&gt;utf8mb4&lt;/code&gt; &lt;code&gt;VARCHAR(63)&lt;/code&gt; is 252 bytes and &lt;code&gt;VARCHAR(64)&lt;/code&gt; is 256. So two changes that look the same in a migration file behave differently:&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="c1"&gt;-- name was VARCHAR(100): 400 bytes, already a two-byte prefix&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;INPLACE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;NONE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Query OK&lt;/span&gt;

&lt;span class="c1"&gt;-- email was VARCHAR(50): 200 bytes, one-byte prefix; 100 characters is 400 bytes&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;INPLACE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;NONE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported.&lt;/span&gt;
&lt;span class="c1"&gt;-- Reason: Cannot change column type INPLACE. Try ALGORITHM=COPY.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The boundary held exactly in the container: &lt;code&gt;utf8mb4&lt;/code&gt; &lt;code&gt;VARCHAR(60)&lt;/code&gt; to &lt;code&gt;VARCHAR(63)&lt;/code&gt; ran in place and &lt;code&gt;VARCHAR(63)&lt;/code&gt; to &lt;code&gt;VARCHAR(64)&lt;/code&gt; was refused; &lt;code&gt;latin1&lt;/code&gt; &lt;code&gt;VARCHAR(100)&lt;/code&gt; to &lt;code&gt;VARCHAR(255)&lt;/code&gt; ran in place and &lt;code&gt;VARCHAR(255)&lt;/code&gt; to &lt;code&gt;VARCHAR(256)&lt;/code&gt; was refused; &lt;code&gt;VARBINARY(255)&lt;/code&gt; to &lt;code&gt;VARBINARY(256)&lt;/code&gt; was refused too. Shrinking a &lt;code&gt;VARCHAR&lt;/code&gt;, growing a &lt;code&gt;CHAR&lt;/code&gt;, and &lt;code&gt;VARCHAR&lt;/code&gt; to &lt;code&gt;TEXT&lt;/code&gt; always copied. So the cost depends on where a column started. A &lt;code&gt;utf8mb4&lt;/code&gt; &lt;code&gt;VARCHAR(50)&lt;/code&gt; copies the table the first time it grows past 63 characters, and grows in place after that; the &lt;code&gt;VARCHAR(191)&lt;/code&gt; many &lt;code&gt;utf8mb4&lt;/code&gt; schemas use is already 764 bytes with a two-byte prefix, so it grows in place from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make MySQL refuse an ALTER instead of copying?
&lt;/h2&gt;

&lt;p&gt;Name the algorithm and lock you expect: with &lt;code&gt;ALGORITHM=INSTANT&lt;/code&gt;, or &lt;code&gt;ALGORITHM=INPLACE, LOCK=NONE&lt;/code&gt;, MySQL refuses a change it cannot do that way instead of falling back to a copy.&lt;/p&gt;

&lt;p&gt;The documentation is explicit that "specifying an &lt;code&gt;ALGORITHM&lt;/code&gt; clause requires the operation to use the specified algorithm ... or fail with an error otherwise", and &lt;code&gt;LOCK=NONE&lt;/code&gt; adds "if supported, permit concurrent reads and writes. Otherwise, an error occurs." That turns a silent decision into a reviewed one. A migration that says&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;users&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;users_status_check&lt;/span&gt;
  &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'closed'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;INPLACE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;NONE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;fails in staging with &lt;code&gt;ERROR 1845 (0A000): ALGORITHM=INPLACE is not supported for this operation. Try ALGORITHM=COPY.&lt;/code&gt; instead of holding back writes in production. Two limits on the clause: &lt;code&gt;INSTANT&lt;/code&gt; takes no &lt;code&gt;LOCK&lt;/code&gt; other than the default, and the clause has to be in the SQL that runs, so check whether the migration your ORM generates carries it or needs a raw-SQL step.&lt;/p&gt;

&lt;p&gt;The clause also catches the case where an &lt;code&gt;INSTANT&lt;/code&gt; change stops being instant without telling you. Each instant add or drop of a column creates a new row version, and the online DDL reference says "the maximum number of row versions permitted is 64 (255 as of MySQL 9.1.0)". On a fresh table, the 65th &lt;code&gt;ADD COLUMN ..., ALGORITHM=INSTANT&lt;/code&gt; failed with &lt;code&gt;ERROR 4092 (HY000): Maximum row versions reached for table t/rv. No more columns can be added or dropped instantly. Please use COPY/INPLACE.&lt;/code&gt; The same &lt;code&gt;ADD COLUMN&lt;/code&gt; without the clause succeeded with no warning, and &lt;code&gt;information_schema.INNODB_TABLES&lt;/code&gt; showed why: &lt;code&gt;TOTAL_ROW_VERSIONS&lt;/code&gt; went from 64 to 0, because MySQL had quietly rebuilt the whole table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can an instant ALTER TABLE still stop reads?
&lt;/h2&gt;

&lt;p&gt;Because every &lt;code&gt;ALTER TABLE&lt;/code&gt; first takes an exclusive metadata lock, it cannot get one while any open transaction has touched the table, and new queries queue behind it while it waits.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/metadata-locking.html" rel="noopener noreferrer"&gt;metadata locking page&lt;/a&gt; explains both halves. A transaction keeps its metadata locks "until the transaction ends", so "a table that is being used by a transaction within one session cannot be used in DDL statements by other sessions until the transaction ends". It also says "write lock requests have higher priority than read lock requests", and in the container a waiting &lt;code&gt;ALTER&lt;/code&gt; held back every query that arrived after it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Session A: &lt;code&gt;BEGIN; SELECT id FROM users WHERE id = 1;&lt;/code&gt; and then nothing for 20 seconds, the shape of a slow report or a forgotten console.&lt;/li&gt;
&lt;li&gt;Session B, one second later: &lt;code&gt;ALTER TABLE users ADD COLUMN nickname VARCHAR(30) NULL, ALGORITHM=INSTANT;&lt;/code&gt;. The process list showed it as &lt;code&gt;Waiting for table metadata lock&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Session C, two seconds after that: &lt;code&gt;SELECT email FROM users WHERE id = 2;&lt;/code&gt;. It waited &lt;strong&gt;17.0 seconds&lt;/strong&gt;, until A committed and B ran.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing about the change was slow; the queue was. And &lt;code&gt;SELECT @@lock_wait_timeout&lt;/code&gt; in the same server returned &lt;code&gt;31536000&lt;/code&gt;, one year, so by default B waits as long as A stays open. The fix is to give the migration session a short timeout and retry:&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;SET&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt; &lt;span class="n"&gt;lock_wait_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&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="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;nickname&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;INSTANT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the same open transaction in A, B gave up after 3 seconds, a query that arrived while B was waiting took 1.6 seconds instead of 17, and the next one took 0.1 seconds. The wait is not only at the start: per the &lt;code&gt;ALTER TABLE&lt;/code&gt; documentation, an &lt;code&gt;INPLACE&lt;/code&gt; change may take the exclusive lock again during execution and a &lt;code&gt;COPY&lt;/code&gt; needs it to swap the tables at the end, so a long build can stall the same way at its finish. Before any migration on a busy table, &lt;code&gt;performance_schema.metadata_locks&lt;/code&gt; shows which sessions hold a lock on the table, and &lt;code&gt;information_schema.INNODB_TRX&lt;/code&gt; how long their transactions have been open.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity shows which MySQL changes copy the table
&lt;/h2&gt;

&lt;p&gt;Schemity's impact analysis marks the MySQL changes that rewrite the table and hold back writes before the migration runs.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. After you &lt;a href="https://schemity.com/doc/connect-mysql/" rel="noopener noreferrer"&gt;connect to MySQL&lt;/a&gt;, a change you draw on the diagram, or one an AI agent proposes, becomes a planned migration, and &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; reports what each statement does to the rows before anything runs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr3h83w25r0r6x5izkrh7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr3h83w25r0r6x5izkrh7.webp" alt="The users and orders tables read from a live MySQL 8.4 database in Schemity: users.email VARCHAR(50), users.name VARCHAR(100), and the foreign key from orders.user_id to users.id" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Widen &lt;code&gt;users.email&lt;/code&gt; from &lt;code&gt;VARCHAR(50)&lt;/code&gt; to &lt;code&gt;VARCHAR(100)&lt;/code&gt; and the findings include "Rewrites users to change email: ~995.6K rows, 70 MB on disk" and, under what holds back other statements, "Changing the type of email holds back writes to users while it reads ~995.6K rows, 70 MB on disk".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fplhipyj6u4stz2kcyqpe.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fplhipyj6u4stz2kcyqpe.webp" alt="Schemity's Impact drawer after users.email is widened from VARCHAR(50) to VARCHAR(100) on MySQL 8.4: Rewrites the table, Rewrites users to change email, ~995.6K rows, 70 MB on disk, and Holds back other statements, Changing the type of email holds back writes to users" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F25uaiomvwuopphhgl1gz.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F25uaiomvwuopphhgl1gz.webp" alt="The Findings panel for the same change: the planned migration is ALTER TABLE users MODIFY COLUMN email VARCHAR(100) with no ALGORITHM clause, one planned change, two lint notes and the two impact findings" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Widen &lt;code&gt;users.name&lt;/code&gt; from &lt;code&gt;VARCHAR(100)&lt;/code&gt; to &lt;code&gt;VARCHAR(200)&lt;/code&gt; instead and neither finding appears, because MySQL grows that column in place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi3wl85lkt765btl4o0nb.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi3wl85lkt765btl4o0nb.webp" alt="Schemity's Impact drawer after users.name is widened from VARCHAR(100) to VARCHAR(200) on MySQL 8.4: Impact (0), No findings, because MySQL grows the column in place" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4avuftepcmasqi9ec7j.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4avuftepcmasqi9ec7j.webp" alt="The Findings panel for the name change: the planned migration is the same kind of ALTER TABLE users MODIFY COLUMN statement, now VARCHAR(200), and Impact shows 0 findings" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Add a &lt;code&gt;CHECK&lt;/code&gt; on &lt;code&gt;users&lt;/code&gt; and the finding is "Checking the new constraint holds back writes to users while it reads" the same row estimate; add a foreign key on &lt;code&gt;orders&lt;/code&gt; and it names &lt;code&gt;orders&lt;/code&gt;. That is the point of being able to see the impact of every change: the two &lt;code&gt;VARCHAR&lt;/code&gt; widenings read the same in a migration file and cost different things.&lt;/p&gt;

&lt;p&gt;The planner also writes the statements MySQL insists on. Changing the type or sign of a column at either end of a foreign key drops the key, changes the columns and adds the key back, because MySQL refuses a type change under a foreign key with error 3780, even with &lt;code&gt;foreign_key_checks&lt;/code&gt; off. Foreign key checks are turned back on before the keys return, so the re-added key checks the rows that are already there, which is also why it copies the referencing table. And the migration dialog warns that MySQL commits each DDL statement immediately, so if a later statement fails, the earlier ones stay applied.&lt;/p&gt;

&lt;p&gt;What Schemity does not do is take the metadata lock for you. The queue in the previous section depends on what other sessions have open when the migration starts, which no diagram can see, so set &lt;code&gt;lock_wait_timeout&lt;/code&gt; in the session that runs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  MySQL ALTER TABLE locks, in one list
&lt;/h2&gt;

&lt;p&gt;Only a copy holds back writes for the whole run, but every &lt;code&gt;ALTER TABLE&lt;/code&gt; can queue reads behind a metadata lock.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adding, dropping or renaming a column and changing a default are usually &lt;code&gt;INSTANT&lt;/code&gt;, until the table reaches 64 row versions on MySQL 8.4, and renaming a column a foreign key uses is &lt;code&gt;INPLACE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Indexes and &lt;code&gt;NOT NULL&lt;/code&gt; run &lt;code&gt;INPLACE&lt;/code&gt;; writes continue.&lt;/li&gt;
&lt;li&gt;Other data type changes, including a &lt;code&gt;VARCHAR&lt;/code&gt; crossing 255 bytes, a &lt;code&gt;CHECK&lt;/code&gt; and a foreign key with checks on copy the table and hold back writes until the copy ends.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;ALGORITHM=INPLACE, LOCK=NONE&lt;/code&gt; or &lt;code&gt;ALGORITHM=INSTANT&lt;/code&gt; to make MySQL refuse instead of copying.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;lock_wait_timeout&lt;/code&gt; to a few seconds before any &lt;code&gt;ALTER TABLE&lt;/code&gt; on a busy table, because even an instant one waits for open transactions and blocks every query behind it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PostgreSQL draws the same lines in different places: its &lt;code&gt;SET NOT NULL&lt;/code&gt; blocks reads and writes while it scans every row, as measured in &lt;a href="https://schemity.com/blog/postgres-add-not-null-column-large-table/" rel="noopener noreferrer"&gt;adding a NOT NULL column to a large table&lt;/a&gt;, and it treats &lt;code&gt;VARCHAR&lt;/code&gt; growth as free at any length, compared with MySQL in &lt;a href="https://schemity.com/blog/postgres-varchar-vs-text/" rel="noopener noreferrer"&gt;VARCHAR vs TEXT&lt;/a&gt;. SQL Server refuses a type change under a key outright, as in &lt;a href="https://schemity.com/blog/sqlserver-change-int-to-bigint-primary-key/" rel="noopener noreferrer"&gt;SQL Server INT to BIGINT&lt;/a&gt;, and what a MySQL key type change costs across a whole foreign key graph is measured in &lt;a href="https://schemity.com/blog/mysql-unsigned-vs-signed-primary-key/" rel="noopener noreferrer"&gt;MySQL UNSIGNED primary keys&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>database</category>
      <category>sql</category>
      <category>devops</category>
    </item>
    <item>
      <title>Laravel ERD: Draw It From the Migration Files or From the Database?</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Mon, 02 Nov 2026 01:09:45 +0000</pubDate>
      <link>https://dev.to/tbson87/laravel-erd-draw-it-from-the-migration-files-or-from-the-database-14e7</link>
      <guid>https://dev.to/tbson87/laravel-erd-draw-it-from-the-migration-files-or-from-the-database-14e7</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; Draw a Laravel ERD from the database that &lt;code&gt;php artisan migrate&lt;/code&gt; built, not from the migration files. In Invoice Ninja v5.13.47 a fresh migrate loads a squashed schema dump and then runs only 61 of the 317 files, and that dump is 10 tables and 43 columns behind the release that ships it. And neither source shows almost half the relations: the database declares 138 foreign keys, while 131 more live only in column names such as &lt;code&gt;activities.client_id&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A Laravel ERD should be drawn from the database that &lt;code&gt;php artisan migrate&lt;/code&gt; built, not from the migration files. The files are a history written in PHP, and the database is the result of running it: what a conditional decided, what a raw SQL string created, what a squashed schema dump replaced. We measured the gap on Invoice Ninja v5.13.47, an open-source Laravel 12.69.3 app with 317 migration files: on an empty MySQL 8.4 database, &lt;code&gt;php artisan migrate&lt;/code&gt; loaded a stored schema dump and then ran only 61 of them.&lt;/p&gt;

&lt;p&gt;The comparison came from readers. Under a video comparing Laravel ERD tools, the most-liked comment asked what happens to the diagram over the lifetime of a project, and the replies split between drawing the diagram from the migration files and reading the database itself. Both camps want a diagram that stays true; they disagree on which source is the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a fresh php artisan migrate actually run?
&lt;/h2&gt;

&lt;p&gt;On an empty database Laravel runs the stored schema dump first and then only the migrations the dump does not already record, so most migration files never execute.&lt;/p&gt;

&lt;p&gt;Laravel's &lt;a href="https://laravel.com/docs/12.x/migrations#squashing-migrations" rel="noopener noreferrer"&gt;migration docs&lt;/a&gt; describe it plainly: when no migrations have been executed, Laravel "will first execute the SQL statements in the schema file of the database connection you are using", then "any remaining migrations that were not part of the schema dump". Invoice Ninja ships that file as &lt;code&gt;database/schema/mysql-schema.sql&lt;/code&gt;, and the migrate output says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO  Loading stored database schemas.
database/schema/mysql-schema.sql ........................... 1s DONE
INFO  Running migrations.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Afterwards the &lt;code&gt;migrations&lt;/code&gt; table holds 319 rows: 258 inserted by the dump, recorded as already run, and 61 that actually ran on top of it. The other 256 files are still in &lt;code&gt;database/migrations&lt;/code&gt; and do not run, including the 1,397-line &lt;code&gt;2014_10_13_000000_create_users_table.php&lt;/code&gt;, whose 50 &lt;code&gt;Schema::create&lt;/code&gt; calls once built most of the schema. On a fresh install none of them execute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is generating an ERD from Laravel migration files unreliable?
&lt;/h2&gt;

&lt;p&gt;Because the files are PHP that has to run to mean anything, and a reader that only parses them has to reproduce Laravel's schema builder, its conditionals and every raw SQL string.&lt;/p&gt;

&lt;p&gt;Invoice Ninja's migrations show each kind of logic a parser has to follow:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the migration does&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Alters an existing table&lt;/td&gt;
&lt;td&gt;over 190&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Schema::table('invoices', ...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branches on the current schema&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;&lt;code&gt;if (!Schema::hasColumn('invoices', 'gateway_fee'))&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drops a column&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$table-&amp;gt;dropColumn('e_invoicing_token')&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Renames a column or table&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;renameColumn&lt;/code&gt;, &lt;code&gt;Schema::rename&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs raw SQL&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DB::statement('CREATE INDEX client_hash_idx ON clients (client_hash(20));')&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The raw SQL line is the one that matters most. &lt;code&gt;client_hash(20)&lt;/code&gt; is a MySQL prefix index, written as a string inside PHP, so a parser of the schema builder never sees it. It is also the index that made MySQL refuse to rebuild Invoice Ninja when an earlier version of Schemity dropped its prefix length, which is how our MySQL round trip on Invoice Ninja found it.&lt;/p&gt;

&lt;p&gt;The conditionals are worse for a diagram than the raw SQL. &lt;code&gt;if (!Schema::hasColumn(...))&lt;/code&gt; means the same file produces different schemas on different databases, so the only way to know which branch ran is to look at the database it ran against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the schema dump a good source for a Laravel ERD?
&lt;/h2&gt;

&lt;p&gt;It is an exact picture of the day it was taken and of nothing after it: Invoice Ninja's dump is 10 tables, 43 columns and 13 foreign keys behind the database the same release builds.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;php artisan schema:dump&lt;/code&gt; writes the output of the database's own dump client (MariaDB, MySQL, PostgreSQL and SQLite only), so it is real DDL rather than PHP, and it imports cleanly. Measured against the migrated database:&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;mysql-schema.sql&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Database after &lt;code&gt;migrate&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Last migration recorded&lt;/td&gt;
&lt;td&gt;October 3 2024&lt;/td&gt;
&lt;td&gt;September 9 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tables&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Columns&lt;/td&gt;
&lt;td&gt;1,586&lt;/td&gt;
&lt;td&gt;1,727&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreign keys&lt;/td&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;td&gt;138&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ten tables missing from the dump are &lt;code&gt;e_invoicing_logs&lt;/code&gt;, &lt;code&gt;e_invoicing_tokens&lt;/code&gt;, &lt;code&gt;elastic_migrations&lt;/code&gt;, &lt;code&gt;job_batches&lt;/code&gt;, &lt;code&gt;locations&lt;/code&gt;, &lt;code&gt;passkey_credentials&lt;/code&gt;, &lt;code&gt;referral_earnings&lt;/code&gt;, &lt;code&gt;taggables&lt;/code&gt;, &lt;code&gt;tags&lt;/code&gt; and &lt;code&gt;verifactu_logs&lt;/code&gt;, and 43 columns were added to tables the dump does have. A diagram drawn from the dump is a diagram of Invoice Ninja in October 2024.&lt;/p&gt;

&lt;h2&gt;
  
  
  Laravel migrations to ERD: which source should the diagram come from?
&lt;/h2&gt;

&lt;p&gt;The migrated database, because it is the only one of the three that is the schema rather than a description of how to build one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What it misses&lt;/th&gt;
&lt;th&gt;What it needs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Migration files&lt;/td&gt;
&lt;td&gt;317 PHP files, over 14,000 lines&lt;/td&gt;
&lt;td&gt;Whatever the conditionals and raw SQL decide; the dump that replaces 256 of the files&lt;/td&gt;
&lt;td&gt;A parser that behaves like Laravel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;schema:dump&lt;/code&gt; file&lt;/td&gt;
&lt;td&gt;One SQL file from the database's dump client&lt;/td&gt;
&lt;td&gt;Every migration after the day it was dumped&lt;/td&gt;
&lt;td&gt;Re-dumping after each migration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database after &lt;code&gt;migrate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The tables, columns, keys and indexes that exist&lt;/td&gt;
&lt;td&gt;Laravel-only meaning: model names, casts, Eloquent relations&lt;/td&gt;
&lt;td&gt;A database; a local one is enough&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cost of the database route is that you need one, and a local MySQL or PostgreSQL in Docker plus one &lt;code&gt;php artisan migrate&lt;/code&gt; is enough. The cost of the file route is a parser that has to know about the dump, read SQL hidden in PHP strings, and guess which branch of each &lt;code&gt;hasColumn&lt;/code&gt; ran on a database whose history differs from a fresh install.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the database know that the migrations never said?
&lt;/h2&gt;

&lt;p&gt;That almost half of Invoice Ninja's relations have no foreign key: the database declares 138, and 131 more exist only as column names, so no source draws them unless something reads the names.&lt;/p&gt;

&lt;p&gt;Those columns were never given a key. &lt;code&gt;activities&lt;/code&gt; is created with &lt;code&gt;$table-&amp;gt;unsignedInteger('client_id')-&amp;gt;nullable();&lt;/code&gt; and an index, and the line that would have made it a foreign key is commented out in the migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;//$table-&amp;gt;foreign('client_id')-&amp;gt;references('id')-&amp;gt;on('clients')-&amp;gt;onDelete('cascade')-&amp;gt;onUpdate('cascade');&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same is true of any Laravel app, whatever helper it uses. Laravel's docs say &lt;code&gt;foreignId&lt;/code&gt; "creates an &lt;code&gt;UNSIGNED BIGINT&lt;/code&gt; equivalent column"; the constraint comes from &lt;code&gt;-&amp;gt;constrained()&lt;/code&gt;, which appears five times in Invoice Ninja's 317 files; a column created without it, or with its &lt;code&gt;foreign()&lt;/code&gt; line left out, is just a column. A migration parser and a schema dump both see exactly what the database sees here: a column, an index and no relation.&lt;/p&gt;

&lt;p&gt;The relations are still in the names. Invoice Ninja's database has 218 &lt;code&gt;_id&lt;/code&gt; columns without a key, and Schemity reads them: it infers 131 relations from columns in 37 tables, such as &lt;code&gt;activities.client_id&lt;/code&gt; to &lt;code&gt;clients&lt;/code&gt; and &lt;code&gt;credits.design_id&lt;/code&gt; to &lt;code&gt;designs&lt;/code&gt;, each drawn dashed and tagged as inferred, never written into a migration. The other 87 stay plain columns. Most name no table at all: &lt;code&gt;status_id&lt;/code&gt; (10 columns) and &lt;code&gt;frequency_id&lt;/code&gt; (5) hold constants from the PHP models, such as &lt;code&gt;Invoice::STATUS_DRAFT = 1&lt;/code&gt;, and others hold ids from outside services, such as the mail provider's &lt;code&gt;message_id&lt;/code&gt; and the bank feed's &lt;code&gt;nordigen_transaction_id&lt;/code&gt;. Some name a table but disagree with its key, and inference skips a pair whose types differ: &lt;code&gt;activities.purchase_order_id&lt;/code&gt; is &lt;code&gt;int unsigned&lt;/code&gt; against a &lt;code&gt;bigint unsigned&lt;/code&gt; key on &lt;code&gt;purchase_orders&lt;/code&gt;, and &lt;code&gt;bank_transactions.expense_id&lt;/code&gt; is &lt;code&gt;text&lt;/code&gt;. And four are the id half of a polymorphic pair, such as &lt;code&gt;paymentable_id&lt;/code&gt; beside &lt;code&gt;paymentable_type&lt;/code&gt;, which points at a different table on every row. While testing this post Schemity inferred &lt;code&gt;paymentables.paymentable_id&lt;/code&gt; as a key to &lt;code&gt;paymentables&lt;/code&gt; itself; it now skips the id half of any &lt;code&gt;_type&lt;/code&gt; pair, a fix that ships in the next release.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4s7ncak3oc8xpr4ut0es.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4s7ncak3oc8xpr4ut0es.webp" alt="Invoice Ninja's migrated database opened in Schemity: a notification reads 131 virtual relations were inferred from column names, drawn dashed and not declared in the database; dashed lines run between tables such as webhooks, company_tokens and accounts among the solid declared foreign keys, and the status bar shows Entities: 80, Relations: 269, MySQL 8.4.11" width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On a smaller view the two kinds are easy to tell apart. In the context view of the Activity &amp;amp; ledger tables, &lt;code&gt;backups.activity_id&lt;/code&gt; is a declared foreign key and is drawn solid, while &lt;code&gt;company_ledgers.activity_id&lt;/code&gt; was never declared and is drawn dashed, read from 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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj3kdx7ci0731fnho9kgi.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj3kdx7ci0731fnho9kgi.webp" alt="Schemity context view Activity &amp;amp; ledger on Invoice Ninja: six tables, with a solid relation from backups.activity_id to activities, a declared foreign key, and a dashed relation from company_ledgers.activity_id to activities, inferred from the column name; activities.client_id shows as a plain column because clients is outside the view, and the status bar shows Entities: 6, Relations: 2" width="800" height="487"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Drawing a Laravel ERD in Schemity
&lt;/h2&gt;

&lt;p&gt;Run the migrations into a local database, connect Schemity to it, and refresh after every &lt;code&gt;php artisan migrate&lt;/code&gt;, so the diagram is always read from what ran.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. &lt;a href="https://schemity.com/doc/connect-mysql/" rel="noopener noreferrer"&gt;Connect it to the MySQL database&lt;/a&gt; your Laravel app migrates, and it draws the tables, the declared foreign keys, the indexes with their prefix lengths, and the &lt;a href="https://schemity.com/doc/virtual-relations/" rel="noopener noreferrer"&gt;inferred relations&lt;/a&gt; on top, each one tagged as read from column names. A Virtual relations menu shows or hides them with a count, removes the inferred ones in one go, and a connection can turn inference off entirely. That is how you understand your schema when the code that built it is 14,000 lines of history.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgwj35vzvipk2debl3ss8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgwj35vzvipk2debl3ss8.webp" alt="Schemity's Virtual relations menu open over Invoice Ninja's main diagram: Show virtual relations (131) ticked and Remove inferred virtual relations (131); behind it the Expenses &amp;amp; projects legend with tasks, recurring_expenses, projects and expenses joined by solid declared foreign keys and many dashed inferred relations, and the status bar showing Entities: 80, Relations: 269" width="800" height="487"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the next &lt;code&gt;php artisan migrate&lt;/code&gt;, &lt;a href="https://schemity.com/doc/resync-database/" rel="noopener noreferrer"&gt;Refresh &amp;amp; merge&lt;/a&gt; re-reads the database and keeps every position, colour, legend and line route you set, with new tables arriving dashed, as one unsaved change you can inspect before saving. A diagram you designed before the tables existed works the same way: connect it to a database that already has them, and the merged diagram opens as an unsaved change, so closing without saving leaves your file as it was.&lt;/p&gt;

&lt;p&gt;When there is no database at hand, Import SQL reads the schema dump instead. On Invoice Ninja's &lt;code&gt;mysql-schema.sql&lt;/code&gt; it read all 70 tables with nothing left unread, without counting the dump's &lt;code&gt;DROP TABLE&lt;/code&gt; statements or its &lt;code&gt;INSERT INTO migrations&lt;/code&gt; rows as skipped, and importing a newer dump onto the same diagram marks each table New, Updated or Unchanged and never removes one. It is still a picture of the day the dump was taken. Imported onto the diagram read from the migrated database, Invoice Ninja's dump finds no new table and marks 29 of its 70 tables Updated: &lt;code&gt;users&lt;/code&gt;, for one, has 44 columns in the dump and 46 in the database, which added &lt;code&gt;referral_earnings&lt;/code&gt; and &lt;code&gt;settings&lt;/code&gt; after October 2024. On a connected diagram, importing it would make those 29 tables pending changes that take the database back to the dump.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwamms5alwswrbta4ryak.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwamms5alwswrbta4ryak.webp" alt="Schemity's Import SQL drawer previewing Invoice Ninja's mysql-schema.sql on the diagram read from the migrated database: tables such as subscriptions, tasks, transaction_events, users with 44 columns, vendor_contacts and vendors marked Updated, others such as sizes, system_logs, tax_rates and webhooks Unchanged, an info box saying updated tables take the script's columns and become pending changes on a connected diagram, Infer virtual relations from column names ticked, and the button Import (0 new, 29 updated)" width="800" height="487"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The other direction is a trade-off rather than a feature. Schemity writes SQL, a migration diff for a connected database, not a Laravel migration file; DrawSQL exports Laravel migrations, as our &lt;a href="https://schemity.com/blog/drawsql-alternative/" rel="noopener noreferrer"&gt;DrawSQL comparison&lt;/a&gt; says. The route that works today is the one in &lt;a href="https://schemity.com/blog/design-first-ship-by-context/" rel="noopener noreferrer"&gt;ERD-first database design&lt;/a&gt;: design the change on the diagram, hand the SQL to your coding agent to write the Laravel migration, run it, and refresh, so the dashed draft tables turn solid without moving.&lt;/p&gt;

&lt;p&gt;Two details above have their own posts: the prefix index &lt;code&gt;client_hash(20)&lt;/code&gt; is what broke our &lt;a href="https://schemity.com/blog/mysql-round-trip-invoice-ninja-sylius/" rel="noopener noreferrer"&gt;MySQL round trip on Invoice Ninja and Sylius&lt;/a&gt;, and &lt;a href="https://schemity.com/blog/postgres-polymorphic-associations/" rel="noopener noreferrer"&gt;polymorphic associations&lt;/a&gt; explains why a column like &lt;code&gt;paymentable_id&lt;/code&gt; can never carry a foreign key.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>mysql</category>
      <category>database</category>
      <category>php</category>
    </item>
    <item>
      <title>pgvector: Embedding Column on the Table or a Separate Embeddings Table?</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Fri, 30 Oct 2026 02:11:01 +0000</pubDate>
      <link>https://dev.to/tbson87/pgvector-embedding-column-on-the-table-or-a-separate-embeddings-table-dc8</link>
      <guid>https://dev.to/tbson87/pgvector-embedding-column-on-the-table-or-a-separate-embeddings-table-dc8</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; Put the embedding where the row it describes is written least. A document that is chunked needs a chunks table anyway; an embedding that will be regenerated by a second model belongs in its own table keyed by chunk and model, with an untyped &lt;code&gt;vector&lt;/code&gt; column. A vector column on a hot parent row costs a 6 KB HNSW entry on every non-HOT edit of any other column: 5,000 title edits took a 31 MB index to 67 MB.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An embedding belongs where the row it describes is written least, and for most RAG schemas that is not the parent table. A document that gets chunked needs a &lt;code&gt;chunks&lt;/code&gt; table whatever you decide, and an embedding you expect to regenerate with a newer model belongs in its own table keyed by chunk and model. The one case for a &lt;code&gt;vector&lt;/code&gt; column on the parent row is one vector per entity, on a row that nothing else updates.&lt;/p&gt;

&lt;p&gt;The reason is what an HNSW index costs on a row that changes for other reasons. We measured it on PostgreSQL 17.11 with pgvector 0.8.7: a &lt;code&gt;documents&lt;/code&gt; table with a &lt;code&gt;vector(1536)&lt;/code&gt; column and an HNSW index, 5,000 rows loaded in one &lt;code&gt;INSERT&lt;/code&gt;, then one &lt;code&gt;UPDATE&lt;/code&gt; that appended a few words to every title and never touched the vector. PostgreSQL made zero HOT updates, the heap went from 2.8 MB to 5.7 MB, and the HNSW index went from 31 MB to 67 MB. &lt;code&gt;VACUUM&lt;/code&gt; left it at 67 MB. The control was a &lt;code&gt;chunk_embeddings&lt;/code&gt; table holding 20,000 vectors of the same size under an 81 MB HNSW index: the same title edit on its parent rows cannot touch it, and did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a vector column makes every update of the row expensive
&lt;/h2&gt;

&lt;p&gt;Every non-HOT update of a row with an HNSW index adds a new, full-size entry to the index, and after a bulk load at the default &lt;code&gt;fillfactor&lt;/code&gt; nothing is HOT.&lt;/p&gt;

&lt;p&gt;The zero is not the vector's doing. A copy of the table with no vector column at all, loaded and updated the same way, also made 0 HOT updates out of 5,000: &lt;code&gt;INSERT ... SELECT&lt;/code&gt; packs each page to the default &lt;code&gt;fillfactor&lt;/code&gt; of 100, so there is no room on the page for a new row version, and a HOT update needs exactly that. The &lt;code&gt;title&lt;/code&gt; column had no index either, so the only indexes in play were the primary key and the HNSW index. What the vector decides is the price of each non-HOT update. A &lt;code&gt;vector(1536)&lt;/code&gt; value is 6,148 bytes (&lt;code&gt;pg_column_size&lt;/code&gt; on the measured table), pgvector declares the type with external storage, so PostgreSQL keeps it out of line and uncompressed in the TOAST table (2.8 MB of heap against 39 MB of TOAST for the 5,000 rows), and the HNSW entry for the new row version carries the whole vector again. That is why one pass of title edits roughly doubled the index.&lt;/p&gt;

&lt;p&gt;Lowering the &lt;code&gt;fillfactor&lt;/code&gt; helps but does not fix it. A copy of the table created &lt;code&gt;WITH (fillfactor = 90)&lt;/code&gt; made 834 of the 5,000 updates HOT: better, and still 4,166 new index entries for an edit that did not change a single vector.&lt;/p&gt;

&lt;p&gt;After &lt;code&gt;VACUUM&lt;/code&gt; the index stays at 67 MB: PostgreSQL never shrinks an index file, and pgvector reuses the freed slots for later inserts, so a second pass of title edits after the vacuum left it at 67 MB and a third took it to 72 MB. Until vacuum runs, pgvector's README warns that dead elements can cut the number of rows a search returns, and it says vacuuming an HNSW index "can take a while", recommending &lt;code&gt;REINDEX INDEX CONCURRENTLY&lt;/code&gt; first. A parent table that is edited every day carries an index that is always part dead and never smaller than its worst day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should pgvector embeddings be in a separate table?
&lt;/h2&gt;

&lt;p&gt;Yes when the text is chunked or when you will ever re-embed; a column on the row only when there is exactly one vector per entity and nothing else on that row is updated.&lt;/p&gt;

&lt;p&gt;Three layouts cover the real cases:&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;Vector column on the parent row&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;chunks&lt;/code&gt; table with a vector column&lt;/th&gt;
&lt;th&gt;Separate &lt;code&gt;chunk_embeddings (chunk_id, model)&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One document, many vectors&lt;/td&gt;
&lt;td&gt;Impossible: one row holds one vector&lt;/td&gt;
&lt;td&gt;Natural: one row per chunk&lt;/td&gt;
&lt;td&gt;Natural&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Second embedding model&lt;/td&gt;
&lt;td&gt;A second column, backfilled by an &lt;code&gt;UPDATE&lt;/code&gt; of every row&lt;/td&gt;
&lt;td&gt;A second column per chunk, same backfill&lt;/td&gt;
&lt;td&gt;New rows under a new &lt;code&gt;model&lt;/code&gt; value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edits to the document's other columns&lt;/td&gt;
&lt;td&gt;New HNSW entry per non-HOT edit (measured above)&lt;/td&gt;
&lt;td&gt;Only when the chunk row itself changes&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A model with a different dimension&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ALTER COLUMN TYPE&lt;/code&gt; refused once rows exist&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;td&gt;An untyped &lt;code&gt;vector&lt;/code&gt; column and one cast partial index per model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filter by tenant or document&lt;/td&gt;
&lt;td&gt;On the same table, where pgvector's remedies work&lt;/td&gt;
&lt;td&gt;Needs &lt;code&gt;document_id&lt;/code&gt; on the chunk, or a join&lt;/td&gt;
&lt;td&gt;Needs the filter column copied onto this table, or a join&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra joins on read&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;One, chunk to document&lt;/td&gt;
&lt;td&gt;Two, embedding to chunk to document&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The middle column is the usual answer. A RAG pipeline splits a document into chunks sized for the model's context, so the chunk row already exists with its position and text, and the vector sits beside the text it encodes. The chunk row changes only when the text changes, which is when the vector must change anyway.&lt;/p&gt;

&lt;p&gt;The right column earns its second join when models change. &lt;code&gt;ALTER TABLE documents ALTER COLUMN embedding TYPE vector(768)&lt;/code&gt; fails the moment a row holds a 1,536-value vector: PostgreSQL answers &lt;code&gt;ERROR: expected 768 dimensions, not 1536&lt;/code&gt;. A new column per model is the same &lt;code&gt;ALTER TABLE&lt;/code&gt; on the table that holds the text, then a backfill &lt;code&gt;UPDATE&lt;/code&gt; of every row, which by the measurement above adds a new entry to the existing HNSW index for every row it touches, then two indexes on one row and a cleanup migration later. A &lt;code&gt;chunk_embeddings&lt;/code&gt; table keyed by &lt;code&gt;(chunk_id, model)&lt;/code&gt; takes the new model as new rows and nothing in the tables that hold the text changes. To hold two sizes at once the column is declared as an untyped &lt;code&gt;vector&lt;/code&gt;, which pgvector's README recommends for mixed dimensions, and each model gets its own partial index over a cast:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;chunk_embeddings&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;chunk_id&lt;/span&gt;  &lt;span class="nb"&gt;bigint&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;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&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="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector&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="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="n"&gt;chunk_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;chunk_embeddings_small&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chunk_embeddings&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'text-embedding-3-small'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;chunk_embeddings_nomic&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chunk_embeddings&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'nomic-embed-text'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measured on the same server: 200 chunks stored under both models, 1,536 and 768 values side by side in one column, both indexes built. Queries use the same cast and the same &lt;code&gt;WHERE model = ...&lt;/code&gt; so the planner picks the right partial index, and the old model's rows are deleted when the switch is done. A model over 2,000 dimensions needs a &lt;code&gt;halfvec&lt;/code&gt; cast instead, which is pgvector's index limit for &lt;code&gt;vector&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The left column is for entity embeddings: one vector per product, per user profile, per support ticket, that changes only when the entity's own text changes, on a row nothing else updates. If the table also carries a &lt;code&gt;view_count&lt;/code&gt; or a &lt;code&gt;last_seen_at&lt;/code&gt;, it is no longer that case, and the index growth above is what you get.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the tenant filter go?
&lt;/h2&gt;

&lt;p&gt;Keep the column you filter on in the same table as the HNSW index, because pgvector's remedies for a filtered search, a partial index per value or an exact B-tree search, only work when the column is on the indexed table.&lt;/p&gt;

&lt;p&gt;pgvector's README is direct about what happens otherwise: with an approximate index, "filtering is applied &lt;em&gt;after&lt;/em&gt; the index is scanned", and with the default &lt;code&gt;hnsw.ef_search&lt;/code&gt; of 40 a condition that matches 10% of rows leaves about 4 candidates. A tenant filter that lives two joins away on &lt;code&gt;documents&lt;/code&gt; is in that position with no index to help it. The README's own remedies are a partial index per filter value when there are few of them, a plain B-tree on the filter column for exact search when the condition is selective, and from pgvector 0.8 iterative index scans (&lt;code&gt;SET hnsw.iterative_scan = strict_order&lt;/code&gt;) that keep scanning until enough rows pass, which work with a join too.&lt;/p&gt;

&lt;p&gt;For the &lt;code&gt;chunk_embeddings&lt;/code&gt; layout that means copying &lt;code&gt;tenant_id&lt;/code&gt; onto the embeddings row. A &lt;code&gt;CHECK&lt;/code&gt; cannot see another table, so the honest way to keep the copy true is a composite foreign key: &lt;code&gt;UNIQUE (id, tenant_id)&lt;/code&gt; on &lt;code&gt;chunks&lt;/code&gt;, then &lt;code&gt;FOREIGN KEY (chunk_id, tenant_id) REFERENCES chunks (id, tenant_id) ON UPDATE CASCADE&lt;/code&gt; on the embeddings table. For the &lt;code&gt;chunks&lt;/code&gt; layout it usually means &lt;code&gt;document_id&lt;/code&gt; is already there and &lt;code&gt;tenant_id&lt;/code&gt; joins in from &lt;code&gt;documents&lt;/code&gt;; put the filter on &lt;code&gt;chunks&lt;/code&gt; too, the same way, if the tenant filter is on every query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing the three tables in Schemity
&lt;/h2&gt;

&lt;p&gt;Schemity draws a pgvector column with its dimension and an HNSW index with its operator class and options exactly as PostgreSQL stores them, and flags an index PostgreSQL will refuse before the migration runs.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. Connect it to a database with pgvector installed, or import the &lt;code&gt;pg_dump&lt;/code&gt; of one, and the layout above is drawn with what pgvector actually stores: &lt;code&gt;embedding VECTOR&lt;/code&gt; on &lt;code&gt;chunk_embeddings&lt;/code&gt; with no dimension, &lt;code&gt;VECTOR(1536)&lt;/code&gt; where one was declared, the composite primary key on &lt;code&gt;(chunk_id, model)&lt;/code&gt;, and each HNSW index with its cast expression, its &lt;code&gt;WHERE&lt;/code&gt; condition, its operator class and its &lt;code&gt;WITH (m=16, ef_construction=64)&lt;/code&gt; in the table's Indexes list, read from &lt;code&gt;reloptions&lt;/code&gt; rather than typed in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq75imkozriiosjxinlq5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq75imkozriiosjxinlq5.webp" alt="The three tables read from a live pgvector database in Schemity: documents with embedding VECTOR(1536) marked nullable and one index, chunks with its composite unique on document_id and position, and chunk_embeddings keyed by chunk_id and model with a bare VECTOR column and two indexes" width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5bw46bh2rcnyihjhzjhy.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5bw46bh2rcnyihjhzjhy.webp" alt="The Indexes list of chunk_embeddings in Schemity's entity editor: two hnsw indexes, each with its cast expression ((embedding)::vector(768)) or ((embedding)::vector(1536)), its vector_cosine_ops operator class and its WHERE model = ... condition, read from the live database" width="800" height="539"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That matters when you design the next one on a schema you did not write. A &lt;code&gt;vector&lt;/code&gt; column with no dimension looks fine on a canvas and builds no plain index: pgvector refuses &lt;code&gt;CREATE INDEX ... USING hnsw&lt;/code&gt; on it with &lt;code&gt;column does not have dimensions&lt;/code&gt;, and refuses it again above 2,000 dimensions for &lt;code&gt;vector&lt;/code&gt; and 4,000 for &lt;code&gt;halfvec&lt;/code&gt;. &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;Schema lint&lt;/a&gt; reports both as "HNSW index on a vector column PostgreSQL will refuse" before the migration runs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj32scb7d05k9h33i00eq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj32scb7d05k9h33i00eq.webp" alt="Schemity's lint drawer with one finding under Fails at runtime: chunk_embeddings.embedding, index chunk_embeddings_embedding_idx, HNSW index on a vector column PostgreSQL will refuse, the column has no dimension; the embedding row is marked red on the chunk_embeddings table" width="799" height="450"&gt;&lt;/a&gt; The &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;change preview&lt;/a&gt; is what shows the model-switch mistake: changing &lt;code&gt;vector(1536)&lt;/code&gt; to &lt;code&gt;vector(768)&lt;/code&gt; on a column that holds rows is drawn as an altered column, with the planned &lt;code&gt;ALTER COLUMN ... TYPE&lt;/code&gt; statement for you to read before it runs, so the &lt;code&gt;chunk_embeddings&lt;/code&gt; rows arrive as a design decision instead of a failed deploy.&lt;/p&gt;

&lt;p&gt;Adding a plain vector index is one dialog: pick HNSW, the operator class pgvector needs (there is no default one, &lt;code&gt;CREATE INDEX ... USING hnsw (embedding)&lt;/code&gt; fails with &lt;code&gt;data type vector has no default operator class for access method "hnsw"&lt;/code&gt;), and the options, and the written SQL is &lt;code&gt;CREATE INDEX ... USING hnsw (embedding vector_cosine_ops) WITH (m=16, ef_construction=64)&lt;/code&gt;. An agent working over MCP creates the same index with &lt;code&gt;create_index&lt;/code&gt;, operator class included, and is declined if it leaves the class out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv8fx15smwr667zcq636q.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv8fx15smwr667zcq636q.webp" alt="Schemity's index dialog for documents_embedding_hnsw: Fields embedding, Type HNSW, Opclass vector_cosine_ops, Options m=16, ef_construction=64, Unique unticked" width="800" height="539"&gt;&lt;/a&gt; The &lt;a href="https://schemity.com/doc/tables-and-fields/" rel="noopener noreferrer"&gt;field editor&lt;/a&gt; labels the length of a &lt;code&gt;VECTOR&lt;/code&gt; column as its dimensions, which is the number the whole decision above turns on.&lt;/p&gt;

&lt;p&gt;The numbers above came from a 5,000-row table on one laptop. Scale them by your row count and your edit rate, and the choice usually makes itself: the same way an &lt;a href="https://schemity.com/blog/postgres-change-int-primary-key-to-bigint/" rel="noopener noreferrer"&gt;integer key that outgrows its type&lt;/a&gt; forces a rewrite you would rather have planned, an embedding on the wrong row forces an index rebuild you would rather never need.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>pgvector</category>
      <category>database</category>
      <category>ai</category>
    </item>
    <item>
      <title>MySQL to PostgreSQL Schema Conversion: What Each Column Type Should Become, and What Silently Changes</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 29 Oct 2026 17:42:12 +0000</pubDate>
      <link>https://dev.to/tbson87/mysql-to-postgresql-schema-conversion-what-each-column-type-should-become-and-what-silently-ecf</link>
      <guid>https://dev.to/tbson87/mysql-to-postgresql-schema-conversion-what-each-column-type-should-become-and-what-silently-ecf</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; Most MySQL types have an obvious PostgreSQL twin, but eight need a decision: &lt;code&gt;TINYINT(1)&lt;/code&gt;, &lt;code&gt;UNSIGNED&lt;/code&gt; integers, &lt;code&gt;DATETIME&lt;/code&gt; vs &lt;code&gt;TIMESTAMP&lt;/code&gt;, &lt;code&gt;ENUM&lt;/code&gt;, &lt;code&gt;SET&lt;/code&gt;, &lt;code&gt;ON UPDATE CURRENT_TIMESTAMP&lt;/code&gt;, &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; and the collation behind every unique text column. The last one fails silently: a MySQL unique email refuses &lt;code&gt;Ana@Example.com&lt;/code&gt; after &lt;code&gt;ana@example.com&lt;/code&gt;, and the same constraint on PostgreSQL accepts both.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Converting a MySQL schema to PostgreSQL is mostly mechanical, and the parts that are not mechanical are the ones that break production. Eight kinds of column need a decision, because the obvious PostgreSQL type either refuses data MySQL accepted, or accepts data MySQL refused. The worst of them changes no type at all: a unique text column that ignored case in MySQL stops ignoring it.&lt;/p&gt;

&lt;p&gt;Every behaviour below was run on MySQL 8.4.11 and PostgreSQL 18.3 in throwaway containers, against this small shop schema:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;is_active&lt;/span&gt; &lt;span class="nb"&gt;TINYINT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&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="n"&gt;updated_at&lt;/span&gt; &lt;span class="nb"&gt;DATETIME&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="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;UPDATE&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;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;customers_email_key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;CHARSET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;utf8mb4&lt;/span&gt; &lt;span class="k"&gt;COLLATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;utf8mb4_0900_ai_ci&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&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="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;ENUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'paid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;)&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;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;options&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'gift'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'express'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'fragile'&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;total&lt;/span&gt; &lt;span class="nb"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&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="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="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_customer_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="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;CHARSET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;utf8mb4&lt;/span&gt; &lt;span class="k"&gt;COLLATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;utf8mb4_0900_ai_ci&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The eight MySQL columns that need a decision
&lt;/h2&gt;

&lt;p&gt;Eight kinds of MySQL column change behaviour when translated literally: &lt;code&gt;TINYINT(1)&lt;/code&gt;, &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt;, &lt;code&gt;DATETIME&lt;/code&gt;, &lt;code&gt;ENUM&lt;/code&gt;, &lt;code&gt;SET&lt;/code&gt;, &lt;code&gt;ON UPDATE CURRENT_TIMESTAMP&lt;/code&gt;, &lt;code&gt;AUTO_INCREMENT&lt;/code&gt;, and unique text columns under a &lt;code&gt;_ci&lt;/code&gt; collation.&lt;/p&gt;

&lt;p&gt;Most columns translate one to one: &lt;code&gt;DECIMAL(10,2)&lt;/code&gt; is &lt;code&gt;NUMERIC(10,2)&lt;/code&gt;, &lt;code&gt;VARCHAR(255)&lt;/code&gt; is &lt;code&gt;VARCHAR(255)&lt;/code&gt;, &lt;code&gt;INT UNSIGNED&lt;/code&gt; fits in &lt;code&gt;bigint&lt;/code&gt;, and a MySQL &lt;code&gt;TIMESTAMP&lt;/code&gt; is &lt;code&gt;timestamptz&lt;/code&gt;. These eight do not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MySQL&lt;/th&gt;
&lt;th&gt;Literal PostgreSQL version&lt;/th&gt;
&lt;th&gt;What changes&lt;/th&gt;
&lt;th&gt;Decide&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TINYINT(1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;boolean&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MySQL stored &lt;code&gt;2&lt;/code&gt; without complaint; &lt;code&gt;boolean&lt;/code&gt; refuses any integer&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;boolean&lt;/code&gt; if the column only holds 0 and 1, &lt;code&gt;smallint&lt;/code&gt; if not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BIGINT UNSIGNED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bigint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Values above 9,223,372,036,854,775,807 fail with &lt;code&gt;bigint out of range&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bigint&lt;/code&gt; for keys, &lt;code&gt;numeric(20,0)&lt;/code&gt; for hashes and external ids that use the full range&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DATETIME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;timestamp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nothing yet, but it keeps no zone, unlike MySQL's &lt;code&gt;TIMESTAMP&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;timestamptz&lt;/code&gt; if the app writes UTC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ENUM(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;varchar&lt;/code&gt; plus &lt;code&gt;CHECK&lt;/code&gt;, or &lt;code&gt;CREATE TYPE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The value list moves from the type to a constraint or a named type&lt;/td&gt;
&lt;td&gt;One of three designs, chosen on purpose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SET(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;varchar&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MySQL refused unknown members; plain text accepts anything&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;text[]&lt;/code&gt; with a containment &lt;code&gt;CHECK&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ON UPDATE CURRENT_TIMESTAMP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;updated_at&lt;/code&gt; stops moving&lt;/td&gt;
&lt;td&gt;A trigger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AUTO_INCREMENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GENERATED BY DEFAULT AS IDENTITY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Loaded ids do not move the counter; the next insert collides&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;setval&lt;/code&gt; after the load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A unique text column under a &lt;code&gt;_ci&lt;/code&gt; collation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UNIQUE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Case and accents start to count&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;lower()&lt;/code&gt; index or a nondeterministic collation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two get their own sections below.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you convert a MySQL schema to PostgreSQL?
&lt;/h2&gt;

&lt;p&gt;Convert the schema first, deciding each of those eight columns yourself, then move the rows with pgloader or &lt;code&gt;COPY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pgloader.readthedocs.io/en/latest/ref/mysql.html" rel="noopener noreferrer"&gt;pgloader&lt;/a&gt; can do both in one pass with its own default casts, and they differ from a literal mapping in places that matter: it casts &lt;code&gt;DATETIME&lt;/code&gt; to &lt;code&gt;timestamptz&lt;/code&gt;, turns each &lt;code&gt;ENUM&lt;/code&gt; into a PostgreSQL enum type named after the table and column, casts &lt;code&gt;TINYINT(1)&lt;/code&gt; to &lt;code&gt;boolean&lt;/code&gt; through its own &lt;code&gt;tinyint-to-boolean&lt;/code&gt; function rather than refusing odd values, gives &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; keys &lt;code&gt;serial&lt;/code&gt; or &lt;code&gt;bigserial&lt;/code&gt;, and resets sequences after the load. Its &lt;code&gt;create no tables&lt;/code&gt; option instead loads into tables that already exist, which is the route when you want to decide each column yourself and write the &lt;code&gt;CREATE TABLE&lt;/code&gt; script first.&lt;/p&gt;

&lt;p&gt;Whichever tool writes the script, read it against the list above before running it. These are the cases from the test run where a literal translation accepted the script and then behaved differently.&lt;/p&gt;

&lt;h3&gt;
  
  
  TINYINT(1): boolean only if the data agrees
&lt;/h3&gt;

&lt;p&gt;MySQL has no real boolean, so &lt;code&gt;BOOLEAN&lt;/code&gt; is &lt;code&gt;TINYINT(1)&lt;/code&gt; and the &lt;code&gt;(1)&lt;/code&gt; is a display width, not a limit. On 8.4, &lt;code&gt;INSERT INTO customers (email, is_active) VALUES ('flag@example.com', 2)&lt;/code&gt; succeeded and stored 2. PostgreSQL's &lt;code&gt;boolean&lt;/code&gt; refuses the same insert with &lt;code&gt;column "is_active" is of type boolean but expression is of type integer&lt;/code&gt;. Before choosing &lt;code&gt;boolean&lt;/code&gt;, count the rows that would not fit:&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it before the load whichever tool moves the rows. Zero means &lt;code&gt;boolean&lt;/code&gt;. Anything else means some code uses the flag as a small number, and &lt;code&gt;smallint&lt;/code&gt; keeps it until that code is found. Changing the column type later is its own migration, covered in &lt;a href="https://schemity.com/blog/postgres-change-integer-column-to-boolean/" rel="noopener noreferrer"&gt;changing an integer column to boolean in Postgres&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  UNSIGNED integers: one size up, except BIGINT
&lt;/h3&gt;

&lt;p&gt;PostgreSQL has no unsigned integers. An &lt;code&gt;INT UNSIGNED&lt;/code&gt; holds up to 4,294,967,295, which fits in &lt;code&gt;bigint&lt;/code&gt;, so the safe mapping is one size up: &lt;code&gt;TINYINT UNSIGNED&lt;/code&gt; to &lt;code&gt;smallint&lt;/code&gt;, &lt;code&gt;SMALLINT UNSIGNED&lt;/code&gt; and &lt;code&gt;MEDIUMINT UNSIGNED&lt;/code&gt; to &lt;code&gt;integer&lt;/code&gt;, &lt;code&gt;INT UNSIGNED&lt;/code&gt; to &lt;code&gt;bigint&lt;/code&gt;. &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; has nowhere to go. MySQL accepted &lt;code&gt;id = 18446744073709551615&lt;/code&gt;; PostgreSQL's &lt;code&gt;bigint&lt;/code&gt; stops at 9,223,372,036,854,775,807 and refused it with &lt;code&gt;bigint out of range&lt;/code&gt;. For an &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; key that will never reach 2^63, &lt;code&gt;bigint&lt;/code&gt; is right. For a column holding 64-bit hashes or another system's ids, check &lt;code&gt;SELECT max(col)&lt;/code&gt; first and use &lt;code&gt;numeric(20,0)&lt;/code&gt; if the top half is in use. Why MySQL schemas are full of &lt;code&gt;UNSIGNED&lt;/code&gt; in the first place is in &lt;a href="https://schemity.com/blog/mysql-unsigned-vs-signed-primary-key/" rel="noopener noreferrer"&gt;signed vs unsigned primary keys in MySQL&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  DATETIME vs TIMESTAMP: only one of them is an instant
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/datetime.html" rel="noopener noreferrer"&gt;MySQL manual&lt;/a&gt; puts the difference in one sentence: "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. (This does not occur for other types such as &lt;code&gt;DATETIME&lt;/code&gt;.)" In the test, both columns were set to &lt;code&gt;2026-10-29 09:00:00&lt;/code&gt; in a session at &lt;code&gt;+07:00&lt;/code&gt; and read back at &lt;code&gt;+00:00&lt;/code&gt;: the &lt;code&gt;TIMESTAMP&lt;/code&gt; returned &lt;code&gt;02:00:00&lt;/code&gt;, the &lt;code&gt;DATETIME&lt;/code&gt; still &lt;code&gt;09:00:00&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So a MySQL &lt;code&gt;TIMESTAMP&lt;/code&gt; is an instant and becomes &lt;code&gt;timestamptz&lt;/code&gt; without debate. A &lt;code&gt;DATETIME&lt;/code&gt; is whatever the application wrote. A literal conversion makes it &lt;code&gt;timestamp&lt;/code&gt;, which is correct only for wall-clock values. If the application wrote UTC, which is the usual case, convert it on the PostgreSQL side once the rows are in:&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;customers&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="k"&gt;AT&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why the naive type costs you later is in &lt;a href="https://schemity.com/blog/postgres-timestamp-vs-timestamptz/" rel="noopener noreferrer"&gt;timestamp vs timestamptz&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  ENUM and SET: the value list has to live somewhere
&lt;/h3&gt;

&lt;p&gt;A MySQL &lt;code&gt;ENUM&lt;/code&gt; becomes either a &lt;code&gt;CHECK&lt;/code&gt; constraint on a &lt;code&gt;varchar&lt;/code&gt;, a PostgreSQL enum type, or a lookup table. That is a design choice with real trade-offs, and it is the subject of &lt;a href="https://schemity.com/blog/postgres-enum-vs-check-constraint-vs-lookup-table/" rel="noopener noreferrer"&gt;enum vs check constraint vs lookup table&lt;/a&gt;. A &lt;code&gt;CHECK&lt;/code&gt; is the easiest to change later.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SET&lt;/code&gt; is the one that loses a rule quietly. Under MySQL 8.4's default strict &lt;code&gt;sql_mode&lt;/code&gt;, &lt;code&gt;INSERT INTO orders (..., options) VALUES (..., 'gift,wrap')&lt;/code&gt; failed with &lt;code&gt;Data truncated for column 'options'&lt;/code&gt;, because &lt;code&gt;wrap&lt;/code&gt; is not a member. Converted to a &lt;code&gt;varchar&lt;/code&gt;, PostgreSQL stored &lt;code&gt;gift,wrap&lt;/code&gt; without a word. Converting the column to an array with a containment check, in place and with its rows, puts the membership rule back:&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;orders&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="k"&gt;options&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&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="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="k"&gt;options&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;string_to_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;','&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="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="k"&gt;options&lt;/span&gt; &lt;span class="k"&gt;SET&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="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_options_check&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;options&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;@&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'gift'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'express'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fragile'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gift,express&lt;/code&gt; became &lt;code&gt;{gift,express}&lt;/code&gt;, an empty set became &lt;code&gt;{}&lt;/code&gt;, and &lt;code&gt;ARRAY['gift', 'wrap']&lt;/code&gt; then failed on &lt;code&gt;orders_options_check&lt;/code&gt;. One MySQL rule does not come back: MySQL stored &lt;code&gt;gift,gift&lt;/code&gt; as &lt;code&gt;gift&lt;/code&gt;, while the array keeps the duplicate.&lt;/p&gt;

&lt;h3&gt;
  
  
  ON UPDATE CURRENT_TIMESTAMP: there is no column option to translate
&lt;/h3&gt;

&lt;p&gt;MySQL's &lt;code&gt;updated_at&lt;/code&gt; moved on every &lt;code&gt;UPDATE&lt;/code&gt; in the test. PostgreSQL has no column clause that does this, so the converted column kept its first value forever, and the &lt;code&gt;UPDATE&lt;/code&gt; reported success. A &lt;code&gt;BEFORE UPDATE&lt;/code&gt; trigger brings the behaviour back:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;set_updated_at&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;customers_set_updated_at&lt;/span&gt; &lt;span class="k"&gt;BEFORE&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;set_updated_at&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One function serves every table; each table needs its own trigger. The trigger against the ORM approach, per framework, is in &lt;a href="https://schemity.com/blog/auto-update-updated-at-column-mysql-postgres-orm/" rel="noopener noreferrer"&gt;how to auto-update an updated_at column&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  AUTO_INCREMENT: identity, and the sequence after the load
&lt;/h2&gt;

&lt;p&gt;Map &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; to &lt;code&gt;GENERATED BY DEFAULT AS IDENTITY&lt;/code&gt;, then reset each sequence with &lt;code&gt;setval&lt;/code&gt; after the rows are loaded, or the next insert collides with a loaded id.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AUTO_INCREMENT&lt;/code&gt; keeps a value the row supplies, which is PostgreSQL's &lt;code&gt;GENERATED BY DEFAULT AS IDENTITY&lt;/code&gt;, and that is what a load needs, since the rows arrive with their ids. The catch is the counter. After loading ids 1 and 2 into a fresh table, the next insert without an id failed with &lt;code&gt;duplicate key value violates unique constraint "customers_pkey"&lt;/code&gt; and &lt;code&gt;Key (id)=(1) already exists&lt;/code&gt;, because the identity sequence never saw the loaded rows. One statement per table fixes it:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;setval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_get_serial_sequence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customers'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pgloader resets the sequences it created unless told &lt;code&gt;reset no sequences&lt;/code&gt;; on tables you created yourself, check with &lt;code&gt;SELECT last_value&lt;/code&gt; from the sequence, or run the &lt;code&gt;setval&lt;/code&gt; anyway. A hand-rolled &lt;code&gt;COPY&lt;/code&gt; never does it. Whether to switch to &lt;code&gt;GENERATED ALWAYS&lt;/code&gt; once the load is done is covered in &lt;a href="https://schemity.com/blog/postgres-serial-vs-identity/" rel="noopener noreferrer"&gt;serial vs identity in Postgres&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is a unique constraint case-sensitive in PostgreSQL?
&lt;/h2&gt;

&lt;p&gt;Yes: PostgreSQL compares text exactly, so a unique email that refused &lt;code&gt;Ana@Example.com&lt;/code&gt; after &lt;code&gt;ana@example.com&lt;/code&gt; in MySQL accepts both, with no error.&lt;/p&gt;

&lt;p&gt;This is the change no type mapping shows. MySQL 8's default collation for &lt;code&gt;utf8mb4&lt;/code&gt; is &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt;: accent-insensitive and case-insensitive. Every comparison goes through it, unique keys included. In the test:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;On the same schema&lt;/th&gt;
&lt;th&gt;MySQL 8.4, &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;PostgreSQL 18.3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Insert &lt;code&gt;ana@example.com&lt;/code&gt;, then &lt;code&gt;Ana@Example.com&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Second insert fails: &lt;code&gt;Duplicate entry 'Ana@Example.com' for key 'customers.customers_email_key'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Both rows stored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert &lt;code&gt;josé@example.com&lt;/code&gt;, then &lt;code&gt;jose@example.com&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Second insert fails: duplicate entry&lt;/td&gt;
&lt;td&gt;Both rows stored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WHERE email = 'ANA@EXAMPLE.COM'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Finds the row&lt;/td&gt;
&lt;td&gt;Finds nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://www.postgresql.org/docs/current/collation.html" rel="noopener noreferrer"&gt;PostgreSQL manual&lt;/a&gt; states it plainly: "All standard and predefined collations are deterministic", meaning two strings are equal only if they are the same bytes. So after the conversion, a sign-up form that MySQL protected now creates a second account for the same person, and a login that typed the address in capitals stops finding it. Nothing errors.&lt;/p&gt;

&lt;p&gt;There are two ways to keep the old rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A unique index on &lt;code&gt;lower(email)&lt;/code&gt;&lt;/strong&gt;, after dropping the plain constraint: &lt;code&gt;CREATE UNIQUE INDEX customers_email_lower_key ON customers (lower(email));&lt;/code&gt;. It refused &lt;code&gt;Ana@Example.com&lt;/code&gt; after &lt;code&gt;ana@example.com&lt;/code&gt;. It does not make &lt;code&gt;WHERE email = ...&lt;/code&gt; case-insensitive, so queries have to say &lt;code&gt;lower(email)&lt;/code&gt; too, and it does nothing for accents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A nondeterministic ICU collation&lt;/strong&gt; on the column. &lt;code&gt;CREATE COLLATION ci_ai (provider = icu, locale = 'und-u-ks-level1', deterministic = false)&lt;/code&gt; ignores case and accents, as &lt;code&gt;_ai_ci&lt;/code&gt; did. With the column set &lt;code&gt;COLLATE ci_ai&lt;/code&gt; and the &lt;code&gt;UNIQUE&lt;/code&gt; added back, &lt;code&gt;JOSE@example.com&lt;/code&gt; was refused after &lt;code&gt;josé@example.com&lt;/code&gt;, and a plain &lt;code&gt;WHERE email = 'Jose@Example.com'&lt;/code&gt; found the row. The manual's &lt;code&gt;und-u-ks-level2&lt;/code&gt; variant ignores case but not accents. The cost is that B-tree deduplication cannot be used on such an index.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not every MySQL column is affected. A column declared &lt;code&gt;COLLATE utf8mb4_bin&lt;/code&gt; was already case-sensitive, and only text columns that sit in a primary key, unique constraint or unique index change who gets in. A non-unique column still changes how &lt;code&gt;WHERE&lt;/code&gt; matches, which is worth a search through the application's queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Converting the schema in Schemity
&lt;/h2&gt;

&lt;p&gt;Schemity writes the PostgreSQL script without changing the diagram, with a report above it that lists every type it changed and the behaviour that does not carry over.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. &lt;a href="https://schemity.com/doc/connect-mysql/" rel="noopener noreferrer"&gt;Connect it to the MySQL database&lt;/a&gt;, or import a &lt;code&gt;mysqldump --no-data&lt;/code&gt; file, and the schema is a diagram in MySQL's own types.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvx0n99s8k38wt6wn9ntw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvx0n99s8k38wt6wn9ntw.webp" alt="Schemity's canvas reading the shop schema from MySQL: orders has id as an unsigned INTEGER marked auto, customer_id as an unsigned BIGINT foreign key, options as SET with an empty default, status as ENUM with the default pending underlined, and total as DECIMAL(10,2); customers has id as an unsigned BIGINT marked auto, email as VARCHAR(255) with the U unique marker, is_active as BOOLEAN defaulting to TRUE, created_at as TIMESTAMP and updated_at as DATETIME, both defaulting to NOW(); a relationship line runs from one customer to many orders" width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From there, the SQL dialog behind &lt;strong&gt;Export &amp;gt; SQL file&lt;/strong&gt; has a &lt;strong&gt;Database&lt;/strong&gt; dropdown: pick PostgreSQL and the script is &lt;a href="https://schemity.com/doc/export-sql-ddl/" rel="noopener noreferrer"&gt;rewritten for it&lt;/a&gt;, with a conversion report above it. The diagram stays a MySQL diagram, and nothing leaves your machine. For the shop schema the report reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- Converted by Schemity from MySQL to PostgreSQL. The diagram itself is unchanged.
-- Review the notes below before running this script.
--
-- Type mappings:
--   BIGINT UNSIGNED -&amp;gt; BIGINT (2 columns)
--   DATETIME -&amp;gt; TIMESTAMP(0) (1 column)
--   DECIMAL(10,2) -&amp;gt; NUMERIC(10,2) (1 column)
--   ENUM('pending','paid','shipped','cancelled') -&amp;gt; VARCHAR(9) (1 column): Allowed values become a CHECK constraint.
--   INTEGER UNSIGNED -&amp;gt; BIGINT (1 column)
--   SET('gift','express','fragile') -&amp;gt; VARCHAR(21) (1 column): A set of values is stored as comma-separated text, unchecked.
--   TIMESTAMP -&amp;gt; TIMESTAMPTZ(0) (1 column)
--
-- Notes:
--   customers.updated_at: ON UPDATE CURRENT_TIMESTAMP is left out: PostgreSQL has no column that sets itself on update.
--   customers.email: Unique in MySQL under utf8mb4_0900_ai_ci, which ignores case and accents; PostgreSQL compares text exactly, so values that differ only in case and accents are both accepted.
--   2 BIGINT UNSIGNED column(s) hold values up to 2^64-1; PostgreSQL's BIGINT stops at 2^63-1.
--   2 character set or collation setting(s) left out: PostgreSQL picks its own.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script under it creates &lt;code&gt;is_active&lt;/code&gt; as &lt;code&gt;BOOLEAN NOT NULL DEFAULT TRUE&lt;/code&gt;, both keys as &lt;code&gt;BIGINT GENERATED BY DEFAULT AS IDENTITY&lt;/code&gt;, and &lt;code&gt;status&lt;/code&gt; with the &lt;code&gt;CHECK ("status" IN ('pending', 'paid', 'shipped', 'cancelled'))&lt;/code&gt;. It ran on PostgreSQL 18.3 as written. The report is also saved as a comment block at the top of the file, so whoever runs the script reads the same notes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F73f3gnvgfc9a08lbxc4l.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F73f3gnvgfc9a08lbxc4l.webp" alt="Schemity's Diagram SQL dialog on the MySQL shop diagram converted to PostgreSQL: the conversion report lists INTEGER UNSIGNED to BIGINT, SET('gift','express','fragile') to VARCHAR(21) stored as comma-separated text, unchecked, and TIMESTAMP to TIMESTAMPTZ(0), with notes that customers.updated_at loses ON UPDATE CURRENT_TIMESTAMP and that customers.email was unique in MySQL under utf8mb4_0900_ai_ci, which ignores case and accents; below it the 33-line script repeats the report as comments and creates customers and orders with identity keys, the status CHECK and the options VARCHAR(21), with Copy and Save SQL file buttons" width="800" height="831"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The conversion makes the literal choice in every row of the table at the top rather than guessing at your data: &lt;code&gt;TINYINT(1)&lt;/code&gt; becomes &lt;code&gt;BOOLEAN&lt;/code&gt;, &lt;code&gt;DATETIME&lt;/code&gt; stays &lt;code&gt;timestamp&lt;/code&gt;, &lt;code&gt;SET&lt;/code&gt; stays text, and the email keeps a plain &lt;code&gt;UNIQUE&lt;/code&gt;. The report covers most of those choices, each tied to the column it is about. Three are yours to remember: whether a &lt;code&gt;TINYINT(1)&lt;/code&gt; really holds only 0 and 1, whether a &lt;code&gt;DATETIME&lt;/code&gt; holds UTC, and the &lt;code&gt;setval&lt;/code&gt; after the load. Make them in the diagram you are converting into: &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;Import SQL&lt;/a&gt; on a PostgreSQL diagram reads the converted script.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr3g2yd8u0d0fcpe6c2ib.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr3g2yd8u0d0fcpe6c2ib.webp" alt="Schemity's canvas after the converted script was imported into a PostgreSQL diagram: orders has id as BIGINT marked auto, customer_id as a BIGINT foreign key, options as VARCHAR(21) with an empty default, status as VARCHAR(9) with the default pending underlined, and total as NUMERIC(10,2), with cc: 1 and idx: 1 in its footer; customers has id as BIGINT marked auto, email as VARCHAR(255) with the U unique marker, is_active as BOOLEAN defaulting to TRUE, created_at as TIMESTAMPTZ(0) and updated_at as TIMESTAMP(0), both defaulting to NOW()" width="799" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From there you design the next one in PostgreSQL's types, change &lt;code&gt;updated_at&lt;/code&gt; to &lt;code&gt;TIMESTAMPTZ&lt;/code&gt;, add the &lt;code&gt;lower(email)&lt;/code&gt; index, and export the final script. The rows still move with pgloader's &lt;code&gt;create no tables&lt;/code&gt; or your own &lt;code&gt;COPY&lt;/code&gt;, followed by the &lt;code&gt;setval&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  MySQL to PostgreSQL, in one list
&lt;/h2&gt;

&lt;p&gt;Most of the conversion is mechanical; these eight columns are not, and each has a short answer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;TINYINT(1)&lt;/code&gt;: &lt;code&gt;boolean&lt;/code&gt; only after &lt;code&gt;SELECT count(*) ... NOT IN (0, 1)&lt;/code&gt; returns zero.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UNSIGNED&lt;/code&gt;: one size up; &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; to &lt;code&gt;bigint&lt;/code&gt; for keys, &lt;code&gt;numeric(20,0)&lt;/code&gt; if values pass 2^63.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TIMESTAMP&lt;/code&gt; to &lt;code&gt;timestamptz&lt;/code&gt;; &lt;code&gt;DATETIME&lt;/code&gt; to &lt;code&gt;timestamptz&lt;/code&gt; too if it holds UTC, converted with &lt;code&gt;AT TIME ZONE 'UTC'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ENUM&lt;/code&gt;: a &lt;code&gt;CHECK&lt;/code&gt;, an enum type or a lookup table, chosen on purpose. &lt;code&gt;SET&lt;/code&gt;: &lt;code&gt;text[]&lt;/code&gt; with a &lt;code&gt;&amp;lt;@&lt;/code&gt; check.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ON UPDATE CURRENT_TIMESTAMP&lt;/code&gt;: a &lt;code&gt;BEFORE UPDATE&lt;/code&gt; trigger on each table.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AUTO_INCREMENT&lt;/code&gt;: &lt;code&gt;GENERATED BY DEFAULT AS IDENTITY&lt;/code&gt;, then &lt;code&gt;setval&lt;/code&gt; after the load.&lt;/li&gt;
&lt;li&gt;Every unique text column under a &lt;code&gt;_ci&lt;/code&gt; collation: a &lt;code&gt;lower()&lt;/code&gt; unique index or a nondeterministic collation, or accept that case now matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choosing a key type while you are redesigning anyway is in &lt;a href="https://schemity.com/blog/postgres-uuid-vs-bigint-primary-key/" rel="noopener noreferrer"&gt;UUID vs bigint primary keys&lt;/a&gt;, and what changes about indexes when you move a unique rule around is in &lt;a href="https://schemity.com/blog/postgres-unique-constraint-vs-unique-index/" rel="noopener noreferrer"&gt;unique constraint vs unique index&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
    </item>
    <item>
      <title>MySQL Workbench Alternative: ER Diagrams Past 250 Tables</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Wed, 28 Oct 2026 16:34:03 +0000</pubDate>
      <link>https://dev.to/tbson87/mysql-workbench-alternative-er-diagrams-past-250-tables-1bk8</link>
      <guid>https://dev.to/tbson87/mysql-workbench-alternative-er-diagrams-past-250-tables-1bk8</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; MySQL Workbench 26, Oracle's rebuild, has no ER diagram: modeling lives on only in Workbench 8.0, which is no longer maintained. And that diagram struggles in two places: at 250 or more objects it may refuse to place them on a diagram, and it draws a relationship only where MySQL stored a foreign key, so schemas from apps that never declared their keys open with no lines. Schemity places every table it reads, infers the missing relations from column names as dashed virtual relations, and re-reads the database each time you open the diagram.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A good MySQL Workbench alternative is one that keeps the diagram correct on the schemas where Workbench's own diagram breaks down: the ones with hundreds of tables, and the ones whose foreign keys MySQL never stored. Those are the two problems people search for after a reverse engineer, and both follow from how Workbench builds its diagram.&lt;/p&gt;

&lt;p&gt;This post covers both, what Schemity does differently, and the long list of things MySQL Workbench still does that Schemity does not try to do.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;MySQL Workbench&lt;/strong&gt; is Oracle's own tool for MySQL, free under the GPL as the Community edition, on Windows, macOS and Linux. Its manual covers &lt;a href="https://dev.mysql.com/doc/workbench/en/" rel="noopener noreferrer"&gt;administrative tasks, performance tools, database development, database design and modeling, and a database migration wizard&lt;/a&gt;. The modeling part reverse engineers a live database into an EER model saved as an &lt;code&gt;.mwb&lt;/code&gt; file, forward engineers a model into a &lt;code&gt;CREATE&lt;/code&gt; script, and synchronizes a model with a database in either direction. One caveat from the same manual: Workbench "is developed and tested with MySQL Server 8.0", and on 8.4 and later "some MySQL Workbench features may not function".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schemity&lt;/strong&gt; does one of those jobs: the diagram. Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. It connects to MySQL and MariaDB, re-reads the schema each time you open the diagram with its connection available, and turns your edits into a reviewed migration. It has no SQL editor, no server administration and no performance dashboards.&lt;/p&gt;

&lt;p&gt;So the comparison is not one tool against another. It is Workbench's modeling chapter against software that does nothing but modeling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does MySQL Workbench 26 Have ER Diagrams?
&lt;/h2&gt;

&lt;p&gt;Not as of October 2026. Oracle rebuilt Workbench as version 26, and its &lt;a href="https://www.mysql.com/products/workbench/" rel="noopener noreferrer"&gt;product page&lt;/a&gt; lists four areas: develop (a script editor and SQL notebooks), administer, analyze performance (performance reports and Visual Explain), and backup and migrate. Data modeling is not one of them, and the same page says earlier Workbench releases have reached end of life and are no longer maintained.&lt;/p&gt;

&lt;p&gt;So everything this post says about Workbench's diagram, from the EER model and Reverse Engineer to Synchronize Model, describes Workbench 8.0. &lt;a href="https://dev.mysql.com/doc/relnotes/workbench/en/" rel="noopener noreferrer"&gt;Its release notes&lt;/a&gt; end at 8.0.47, released in April 2026, and the screenshot below comes from that line. If your ER diagrams live in Workbench, they live in a version that gets no more fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does MySQL Workbench Reverse Engineer Show No Relationships?
&lt;/h2&gt;

&lt;p&gt;Workbench draws a relationship for each foreign key. Its manual files relationships under &lt;a href="https://dev.mysql.com/doc/workbench/en/wb-relationship-tools.html" rel="noopener noreferrer"&gt;"Adding Foreign Key Relationships Using an EER Diagram"&lt;/a&gt;, with six foreign key tools, and double-clicking a line opens the foreign key's properties. A relationship in a Workbench model is a foreign key.&lt;/p&gt;

&lt;p&gt;That is a sound design until the database has no foreign keys to read, and plenty of InnoDB databases have none. Older Rails apps, Laravel migrations that add an &lt;code&gt;unsignedBigInteger&lt;/code&gt; column without &lt;code&gt;constrained()&lt;/code&gt;, and Django models with &lt;code&gt;db_constraint=False&lt;/code&gt; all leave the key undeclared; some teams drop foreign keys to speed up bulk loads and online schema changes; and a schema that grew one hand-written migration at a time often declared keys on some tables and not others. Tables like these are ordinary:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&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="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&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="n"&gt;customer_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&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="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_id&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The column is named for &lt;code&gt;customers&lt;/code&gt; and indexed for joins, but MySQL has no foreign key on record, so &lt;code&gt;information_schema.REFERENTIAL_CONSTRAINTS&lt;/code&gt; has nothing for &lt;code&gt;orders&lt;/code&gt;. The dependency is real in the data; the database just never heard about it.&lt;/p&gt;

&lt;p&gt;Workbench opens such a model with no lines between the tables, which is exactly what the database declares. You can draw each one with its Place a Relationship Using Existing Columns tool, but in a Workbench model every relationship is a foreign key, so Synchronize Model would then offer to create it in the database as a real constraint.&lt;/p&gt;

&lt;p&gt;Here is a 26-table MySQL 8.4 shop reverse engineered into Workbench. Its migrations declared 14 foreign keys and left 16 &lt;code&gt;*_id&lt;/code&gt; columns undeclared, the way a schema grows when some migrations used &lt;code&gt;constrained()&lt;/code&gt; and later ones did not:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5j6ttczzls2mdt247lyx.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5j6ttczzls2mdt247lyx.webp" alt="MySQL Workbench EER diagram of a 26-table shop schema after Reverse Engineer: tables placed in a uniform grid with no grouping, wishlist_items listing product_variant_id and wishlist_id, dashed relationship lines that run behind other tables, and the connection tab reading Warning - not supported against the MySQL 8.4 server" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Workbench draws the 14 declared keys, dashed because it draws every non-identifying relationship that way, and nothing for the other 16: &lt;code&gt;wishlist_items.product_variant_id&lt;/code&gt;, &lt;code&gt;refunds.payment_id&lt;/code&gt; and &lt;code&gt;audit_logs.user_id&lt;/code&gt; have no line of their own. The connection tab also shows the 8.4 caveat from the manual in practice: it reads "Warning - not supported".&lt;/p&gt;

&lt;h3&gt;
  
  
  How Schemity Draws the Relationships MySQL Never Stored
&lt;/h3&gt;

&lt;p&gt;Schemity reads the declared foreign keys first, the same as Workbench. Then it looks at what is left. A column like &lt;code&gt;customer_id&lt;/code&gt; is split into tokens, the trailing &lt;code&gt;id&lt;/code&gt; is dropped, and the rest is matched against table names and their singular forms, so &lt;code&gt;customer_id&lt;/code&gt; finds &lt;code&gt;customers&lt;/code&gt;. The referenced column is the table's primary key, or failing that its only single-column unique key, or a column named &lt;code&gt;id&lt;/code&gt;. Candidates are skipped when the column types disagree, when two tables could answer to the same name, when either side is a view, or when a real foreign key already covers the column.&lt;/p&gt;

&lt;p&gt;Each match becomes a &lt;a href="https://schemity.com/doc/virtual-relations/" rel="noopener noreferrer"&gt;virtual relation&lt;/a&gt;: a relation Schemity documents but the database does not declare. A virtual relation is drawn dashed, saved in the diagram file, and never written to the database by a migration, a SQL export or a DBML export. One notification says how many were inferred, with a Remove all action, and a relation you delete is remembered so the next refresh does not bring it back. If you would rather see only what MySQL declares, inference can be turned off per connection.&lt;/p&gt;

&lt;p&gt;On the schema above, &lt;code&gt;orders.customer_id&lt;/code&gt; opens with a dashed line to &lt;code&gt;customers&lt;/code&gt;, because both columns are &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt;; a signed &lt;code&gt;customers.id&lt;/code&gt; would get no line.&lt;/p&gt;

&lt;p&gt;The same 26-table shop in Schemity, connected to the same database:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F78b7r2lem0bivg7juxde.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F78b7r2lem0bivg7juxde.webp" alt="Schemity diagram of the same 26-table MySQL shop, grouped into Orders, Customers and Fulfilment legends: declared foreign keys drawn as solid lines and the 16 inferred relations as dashed lines, plain columns such as carts.customer_id and order_coupons.coupon_id marked with a hash icon instead of the foreign key icon, a minimap in the corner and a status bar reading Entities: 26, Relations: 30, MySQL: 8.4.6" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The status bar counts 30 relations: the 14 the database declares, solid, and the 16 inferred from column names, dashed. In Schemity a dashed line means the database does not enforce the link, so &lt;code&gt;order_coupons.coupon_id&lt;/code&gt; and &lt;code&gt;carts.customer_id&lt;/code&gt; read at a glance as references nothing protects. The line tells you the dependency exists. The dash tells you MySQL will not enforce it, so an order can still point at a customer that was deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens When You Reverse Engineer More Than 250 Tables?
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.mysql.com/doc/workbench/en/wb-reverse-engineer-live.html" rel="noopener noreferrer"&gt;reverse engineering page of the Workbench manual&lt;/a&gt; states the limit itself: "Importing 250 or more objects could fail to create an EER diagram and instead emit a resource warning with the text 'Too many objects are selected for auto placement. Select fewer elements to create the EER diagram.'"&lt;/p&gt;

&lt;p&gt;The documented workaround is to run the wizard again with Place imported objects on a diagram unticked, create the EER diagram by hand, import the objects from the catalog, and then lay them out with Arrange, Autolayout or by hand. The wizard's object list also includes views and routines, not only tables.&lt;/p&gt;

&lt;p&gt;Schemity places every table it reads. It was tested on a generated PostgreSQL schema of 1,035 tables and 1,822 relations, which opens in about 10 seconds on a 16 GB M1 Pro because only the tables on screen are drawn up front. A minimap and a fuzzy search over table, column and legend names do the navigating, and &lt;a href="https://schemity.com/doc/context-views/" rel="noopener noreferrer"&gt;context views&lt;/a&gt; split a large schema into focused diagrams by domain while the main view keeps every table. A context view is a sub-diagram of the same schema: the tables are shared, only the selection and layout differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the Diagram Stay in Sync With the Database?
&lt;/h2&gt;

&lt;p&gt;Credit to Workbench here, because it has a real answer. Synchronize Model compares an &lt;code&gt;.mwb&lt;/code&gt; model with a live database and lets you set each difference to Update Model, Update Source or Ignore, then save or run the SQL.&lt;/p&gt;

&lt;p&gt;The difference is when it happens. A Workbench model is a file that changes when you run the wizard. Schemity re-reads the database each time you open the diagram with its connection available, and folds the changes into your layout: positions, colours, legends and relation routes stay where you put them, and tables the database gained since your last save arrive with a dashed border so you can find them. After an ORM migration runs outside Schemity, Refresh &amp;amp; merge does the same on demand, as one unsaved change you can review before saving.&lt;/p&gt;

&lt;p&gt;Going the other way, an edit on the diagram becomes a &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL diff&lt;/a&gt; against the live database, and &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; reads it before anything runs: which statements fail on existing rows, which drop data, and which may rewrite the table, with the row count taken from &lt;code&gt;table_rows&lt;/code&gt; so nothing is scanned. On MySQL it treats every column type change as a rewrite, the cautious reading, since MySQL copies the table for most of them. Nothing touches the database until you apply it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does It Keep MySQL's Own Column Details?
&lt;/h2&gt;

&lt;p&gt;A diagram that rounds MySQL off to generic SQL is a drawing, not a model. Schemity reads and writes the details MySQL schemas actually carry:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;UNSIGNED&lt;/code&gt; integers, shown as &lt;code&gt;uBIGINT&lt;/code&gt; on the canvas, with foreign key columns following their key's sign, so a relation to a Laravel or WordPress &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; key migrates instead of being refused&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ON UPDATE CURRENT_TIMESTAMP&lt;/code&gt;, &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; and column &lt;code&gt;COMMENT&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;table and column character sets and collations&lt;/li&gt;
&lt;li&gt;prefix indexes such as &lt;code&gt;client_hash(20)&lt;/code&gt; and functional indexes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ENUM&lt;/code&gt; and &lt;code&gt;SET&lt;/code&gt; defaults, and &lt;code&gt;(CURDATE())&lt;/code&gt; and &lt;code&gt;(CURTIME())&lt;/code&gt; defaults&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These were tested by rebuilding two real MySQL schemas, Invoice Ninja and Sylius, from Schemity's own SQL, which is where several of them were found missing and fixed. Connections take a pasted MySQL URI or a JDBC string, go direct or over SSH, and log in with IAM tokens on AWS RDS, Azure and Cloud SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  MySQL Workbench 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;MySQL Workbench&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;SQL editor, administration, performance tools, modeling and migration in one app&lt;/td&gt;
&lt;td&gt;Database design software: the diagram, migrations and their impact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ER diagrams today&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Only in Workbench 8.0, end of life; none in Workbench 26&lt;/td&gt;
&lt;td&gt;The core of the product, in every release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Price&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free Community edition under the GPL&lt;/td&gt;
&lt;td&gt;$129 one-time, 1 year of updates, 2-week free trial&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, plus a migration wizard from other databases&lt;/td&gt;
&lt;td&gt;MySQL, MariaDB, PostgreSQL, Supabase, SQL Server, SQLite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Server versions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tested with MySQL 8.0; some features may not work on 8.4 and later&lt;/td&gt;
&lt;td&gt;Tested against MySQL 8.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Diagram size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;250 or more objects may fail auto placement&lt;/td&gt;
&lt;td&gt;Every table placed; tested at 1,035 tables (PostgreSQL)&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;Drawn from stored foreign keys&lt;/td&gt;
&lt;td&gt;Stored foreign keys, plus dashed relations inferred from column names&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Staying current&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Synchronize Model, run by hand&lt;/td&gt;
&lt;td&gt;Re-read on open when connected, layout kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Schema changes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Synchronize or forward engineer, then run the SQL&lt;/td&gt;
&lt;td&gt;Migration SQL diff with impact analysis, applied only when you approve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.mwb&lt;/code&gt; model file&lt;/td&gt;
&lt;td&gt;One JSON file per diagram, in a folder you choose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI agents&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Local database MCP server for Claude Code, Cursor and other MCP clients&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where MySQL Workbench Wins
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It is free, and it is Oracle's.&lt;/strong&gt; For a MySQL shop that wants its tools from the database vendor, that is a real reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is more than a diagram.&lt;/strong&gt; Running queries, managing users, reading performance reports and migrating from another database all live in Workbench. Schemity does none of them, so you will still want Workbench, or another SQL client, for the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model-first work is built into 8.0.&lt;/strong&gt; Workbench 8.0 generates &lt;code&gt;CREATE&lt;/code&gt; scripts and synchronization SQL from an EER model, and draws identifying and non-identifying relationships as solid and dashed lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It reads &lt;code&gt;.mwb&lt;/code&gt; files.&lt;/strong&gt; Schemity does not. Moving an existing model means pointing Schemity at the same database, or exporting the model through File, Export, Forward Engineer SQL CREATE Script and importing that SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is a Good MySQL Workbench Alternative for ER Diagrams?
&lt;/h2&gt;

&lt;p&gt;For the diagram specifically, the questions that decide it are the two above. Does the tool still place every table when the schema passes a few hundred? And does it show the dependencies MySQL never stored, without pretending the database enforces them?&lt;/p&gt;

&lt;p&gt;For a large or undocumented MySQL schema, Schemity is built to answer both, which is what lets you understand your schema before you change it: every table on one canvas, the declared keys solid, the inferred ones dashed, and a diagram that is current each time you open it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should Use MySQL Workbench
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You want one free tool for queries and administration, and can keep an unmaintained Workbench 8.0 for modeling.&lt;/li&gt;
&lt;li&gt;Your schemas are under a couple of hundred objects and declare their foreign keys.&lt;/li&gt;
&lt;li&gt;Your team already keeps &lt;code&gt;.mwb&lt;/code&gt; models and forward engineers from them.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Your MySQL or MariaDB schema has hundreds of tables, or arrived without foreign keys.&lt;/li&gt;
&lt;li&gt;You want the diagram to match the database every time you open it, without running a wizard.&lt;/li&gt;
&lt;li&gt;You want each schema change reviewed with its cost before it runs, and the diagram kept in Git.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;MySQL Workbench is a capable free toolbox, but its modeling now lives only in the unmaintained 8.0 line, and works best on a schema that is small and declares its keys. When the reverse engineer shows no relationships or stops at the placement warning, keep Workbench for queries and administration, and &lt;a href="https://schemity.com/doc/connect-mysql/" rel="noopener noreferrer"&gt;connect Schemity to the same database&lt;/a&gt; for the diagram.&lt;/p&gt;

&lt;p&gt;For how the scale test behaved, read &lt;a href="https://schemity.com/blog/database-diagram-1000-tables-memory-cpu/" rel="noopener noreferrer"&gt;what breaks when a database diagram reaches 1,000 tables&lt;/a&gt;; for the MySQL details, &lt;a href="https://schemity.com/blog/mysql-round-trip-invoice-ninja-sylius/" rel="noopener noreferrer"&gt;round-tripping Invoice Ninja and Sylius through a database diagram&lt;/a&gt;; and if your diagram ever had to draw a 1:1 relationship, &lt;a href="https://schemity.com/blog/why-erd-tools-draw-1-1-relationships-as-1-n/" rel="noopener noreferrer"&gt;why ERD tools draw 1:1 relationships as 1:N&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>database</category>
      <category>sql</category>
      <category>tooling</category>
    </item>
  </channel>
</rss>
