<?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>PostgreSQL serial vs identity: Which to Use, and How to Convert Old serial Columns</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Mon, 05 Oct 2026 04:37:38 +0000</pubDate>
      <link>https://dev.to/tbson87/postgresql-serial-vs-identity-which-to-use-and-how-to-convert-old-serial-columns-4h59</link>
      <guid>https://dev.to/tbson87/postgresql-serial-vs-identity-which-to-use-and-how-to-convert-old-serial-columns-4h59</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Use &lt;code&gt;GENERATED ALWAYS AS IDENTITY&lt;/code&gt; for new tables. &lt;code&gt;serial&lt;/code&gt; is a shortcut for a separate sequence plus a default, so the column and its counter drift apart: an explicit id breaks the next insert, a widened key still stops at 2,147,483,647, and a copied table shares the counter. An existing &lt;code&gt;serial&lt;/code&gt; key converts to identity in a few milliseconds, with no table rewrite.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a new PostgreSQL table, use an identity column: &lt;code&gt;id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/code&gt;. &lt;code&gt;serial&lt;/code&gt; still works, but it is not a type. It is a shortcut that creates a separate sequence and a column default, and the two can drift apart in ways an identity column does not allow.&lt;/p&gt;

&lt;p&gt;Many production schemas still have &lt;code&gt;serial&lt;/code&gt; keys, because the frameworks that created them used it, and two of the big ones still do. Below are the five ways &lt;code&gt;serial&lt;/code&gt; misbehaves, each run on PostgreSQL 18.3 in a throwaway container, and the conversion, which is cheaper than most people expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What serial actually creates
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.postgresql.org/docs/current/datatype-numeric.html#DATATYPE-SERIAL" rel="noopener noreferrer"&gt;PostgreSQL manual&lt;/a&gt; says the serial types "are not true types, but merely a notational convenience". &lt;code&gt;id serial&lt;/code&gt; becomes three things: a sequence &lt;code&gt;users_id_seq AS integer&lt;/code&gt;, a column &lt;code&gt;id integer NOT NULL DEFAULT nextval('users_id_seq')&lt;/code&gt;, and an &lt;code&gt;OWNED BY&lt;/code&gt; link so the sequence is dropped with the column. Identity columns arrived in &lt;a href="https://www.postgresql.org/docs/release/10.0/" rel="noopener noreferrer"&gt;PostgreSQL 10&lt;/a&gt;, described in the release notes as "similar to &lt;code&gt;SERIAL&lt;/code&gt; columns, but are SQL standard compliant".&lt;/p&gt;

&lt;p&gt;The framework you use decides which one you have:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;What it creates for an auto-increment key on PostgreSQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Django 4.1 and later&lt;/td&gt;
&lt;td&gt;Identity column, &lt;code&gt;GENERATED BY DEFAULT&lt;/code&gt;. The &lt;a href="https://docs.djangoproject.com/en/5.2/releases/4.1/" rel="noopener noreferrer"&gt;4.1 release notes&lt;/a&gt;: &lt;code&gt;AutoField&lt;/code&gt;, &lt;code&gt;BigAutoField&lt;/code&gt; and &lt;code&gt;SmallAutoField&lt;/code&gt; "are now created as identity columns rather than serial columns with sequences"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rails (Active Record)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bigserial primary key&lt;/code&gt;, the PostgreSQL adapter's default primary key type in &lt;a href="https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb" rel="noopener noreferrer"&gt;the current source&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prisma&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SERIAL&lt;/code&gt;, &lt;code&gt;SMALLSERIAL&lt;/code&gt; or &lt;code&gt;BIGSERIAL&lt;/code&gt; for &lt;code&gt;@default(autoincrement())&lt;/code&gt;, in the &lt;a href="https://github.com/prisma/prisma-engines/blob/main/schema-engine/connectors/sql-schema-connector/src/flavour/postgres/renderer.rs" rel="noopener noreferrer"&gt;Postgres renderer&lt;/a&gt; of its migration engine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;\d users&lt;/code&gt; tells you which one a table has. A &lt;code&gt;serial&lt;/code&gt; key shows &lt;code&gt;nextval('users_id_seq'::regclass)&lt;/code&gt; as its default. An identity key shows &lt;code&gt;generated always as identity&lt;/code&gt; or &lt;code&gt;generated by default as identity&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serial vs identity in Postgres: which should you use?
&lt;/h2&gt;

&lt;p&gt;Identity, for every new table. The differences only show up when someone does something slightly unusual, which is why &lt;code&gt;serial&lt;/code&gt; lasts so long in old schemas. Each row below was reproduced on 18.3:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;&lt;code&gt;serial&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;GENERATED ALWAYS AS IDENTITY&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;An &lt;code&gt;INSERT&lt;/code&gt; supplies &lt;code&gt;id = 1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Accepted. The next generated id is also 1 and fails with &lt;code&gt;duplicate key value violates unique constraint "users_pkey"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Refused: &lt;code&gt;cannot insert a non-DEFAULT value into column "id"&lt;/code&gt;, with the hint &lt;code&gt;Use OVERRIDING SYSTEM VALUE to override&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An app role has &lt;code&gt;INSERT&lt;/code&gt; on the table only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;permission denied for sequence users_id_seq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The insert works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CREATE TABLE copy (LIKE users INCLUDING ALL)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The copy's default calls &lt;code&gt;users_id_seq&lt;/code&gt;, so both tables draw from one counter&lt;/td&gt;
&lt;td&gt;The copy gets its own sequence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The key is widened to &lt;code&gt;bigint&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The sequence stays &lt;code&gt;AS integer&lt;/code&gt; and fails at 2,147,483,647&lt;/td&gt;
&lt;td&gt;The sequence becomes &lt;code&gt;bigint&lt;/code&gt; with the column&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Someone tries to remove the default&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DROP DEFAULT&lt;/code&gt; works and the column stops numbering&lt;/td&gt;
&lt;td&gt;Refused: &lt;code&gt;Use ALTER TABLE ... ALTER COLUMN ... DROP IDENTITY instead&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;GENERATED BY DEFAULT AS IDENTITY&lt;/code&gt; is the in-between form. It fixes the permissions, copy and widening rows, but accepts a supplied id the way &lt;code&gt;serial&lt;/code&gt; does, and the same &lt;code&gt;duplicate key value&lt;/code&gt; error came back in the same test. Use &lt;code&gt;ALWAYS&lt;/code&gt; unless a loader must write ids, and when one must, &lt;code&gt;INSERT ... OVERRIDING SYSTEM VALUE&lt;/code&gt; says so in the statement. &lt;code&gt;pg_dump&lt;/code&gt; handles it already: its &lt;code&gt;--inserts&lt;/code&gt; output writes &lt;code&gt;INSERT INTO ... OVERRIDING SYSTEM VALUE VALUES (...)&lt;/code&gt;, and its default &lt;code&gt;COPY&lt;/code&gt; output loads explicit ids into an &lt;code&gt;ALWAYS&lt;/code&gt; column without complaint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The serial overflow that survives the bigint migration
&lt;/h2&gt;

&lt;p&gt;The widening row is the one that costs an outage. A table outgrows &lt;code&gt;integer&lt;/code&gt;, someone runs the usual schema migration, &lt;code&gt;ALTER TABLE users ALTER COLUMN id TYPE bigint&lt;/code&gt;, and the column now holds values up to 9,223,372,036,854,775,807. The sequence does not. &lt;code&gt;serial&lt;/code&gt; created it &lt;code&gt;AS integer&lt;/code&gt;, the &lt;code&gt;ALTER&lt;/code&gt; did not touch it, and on 18.3 the next insert after 2,147,483,647 failed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  nextval: reached maximum value of sequence "users_id_seq" (2147483647)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is one more statement, &lt;code&gt;ALTER SEQUENCE users_id_seq AS bigint&lt;/code&gt;, and it is easy to miss because &lt;code&gt;\d users&lt;/code&gt; shows &lt;code&gt;bigint&lt;/code&gt; and looks done. An identity column needs nothing: after the same &lt;code&gt;ALTER&lt;/code&gt;, &lt;code&gt;pg_sequences&lt;/code&gt; showed its sequence as &lt;code&gt;bigint&lt;/code&gt; with a maximum of 9,223,372,036,854,775,807, and the insert at 2,147,483,648 succeeded.&lt;/p&gt;

&lt;p&gt;The column change itself is the expensive part either way. &lt;code&gt;integer&lt;/code&gt; to &lt;code&gt;bigint&lt;/code&gt; rewrites the table: 761 ms for 1,000,000 rows here, under an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock, and every foreign key column pointing at it needs the same change before ids pass 2,147,483,647. If a view reads the key, PostgreSQL refuses the &lt;code&gt;ALTER&lt;/code&gt; outright until &lt;a href="https://schemity.com/blog/postgres-alter-column-type-used-by-view/" rel="noopener noreferrer"&gt;the view is dropped and created again&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A foreign key declared as serial fills itself in
&lt;/h2&gt;

&lt;p&gt;The same shortcut causes a quieter bug in child tables. Copy the parent's column type into a foreign key and you get this:&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;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="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&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="nb"&gt;serial&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="n"&gt;note&lt;/span&gt; &lt;span class="nb"&gt;text&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;user_id&lt;/code&gt; now has its own sequence and a &lt;code&gt;nextval&lt;/code&gt; default. An &lt;code&gt;INSERT INTO orders (note) VALUES ('forgot user_id')&lt;/code&gt; should fail on the &lt;code&gt;NOT NULL&lt;/code&gt; that &lt;code&gt;serial&lt;/code&gt; implies. Instead it succeeded and returned &lt;code&gt;user_id = 1&lt;/code&gt;, and the foreign key passed because user 1 exists. Each later insert that forgets the column attaches the order to user 2, then 3, until it reaches an id with no user and the foreign key finally fails. A foreign key column takes the plain type under the parent's key, &lt;code&gt;integer&lt;/code&gt; for &lt;code&gt;serial&lt;/code&gt; and &lt;code&gt;bigint&lt;/code&gt; for &lt;code&gt;bigserial&lt;/code&gt;, with &lt;code&gt;NOT NULL&lt;/code&gt; when the relationship is mandatory. Never the serial itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you convert a serial column to identity?
&lt;/h2&gt;

&lt;p&gt;Without rewriting the table. The conversion swaps the default for an identity property and carries the old counter over, so it touches the catalogue, not the rows. Here on an &lt;code&gt;invoices&lt;/code&gt; table created with &lt;code&gt;id serial&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;BEGIN&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;invoices&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;id&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="n"&gt;SEQUENCE&lt;/span&gt; &lt;span class="n"&gt;invoices_id_seq&lt;/span&gt; &lt;span class="k"&gt;RENAME&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;invoices_id_seq_old&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;invoices&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;id&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&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;setval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'invoices_id_seq'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_called&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;invoices_id_seq_old&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="n"&gt;SEQUENCE&lt;/span&gt; &lt;span class="n"&gt;invoices_id_seq_old&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a 1,000,000-row table whose top ten rows had been deleted, the transaction took about 3 ms, the table's data file was the same one before and after, and the next insert got &lt;code&gt;id = 1000001&lt;/code&gt;. Copying &lt;code&gt;last_value&lt;/code&gt; rather than &lt;code&gt;max(id)&lt;/code&gt; is deliberate: &lt;code&gt;max(id)&lt;/code&gt; would hand out the deleted ids 999,991 to 1,000,000 again, and anything that still refers to them, a log line, an export, another system, would now point at a new row. The &lt;code&gt;setval&lt;/code&gt; names the new sequence directly, because &lt;code&gt;pg_get_serial_sequence&lt;/code&gt; can still return the renamed old one, which stays owned by the column until it is dropped. The new sequence takes the old name, &lt;code&gt;invoices_id_seq&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Four things to check first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The lock.&lt;/strong&gt; The transaction holds &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; on the table. It is short, but it waits behind every running query, and everything else waits behind it. Set &lt;code&gt;lock_timeout&lt;/code&gt; so a long report cannot turn a 3 ms change into a queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A shared sequence.&lt;/strong&gt; If a &lt;code&gt;LIKE ... INCLUDING ALL&lt;/code&gt; copy or another table also calls the sequence, &lt;code&gt;DROP SEQUENCE&lt;/code&gt; fails with &lt;code&gt;cannot drop sequence invoices_id_seq_old because other objects depend on it&lt;/code&gt;, its &lt;code&gt;DETAIL&lt;/code&gt; names every default using it, and the transaction rolls back. That is how you find the copies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grants on the sequence.&lt;/strong&gt; They are dropped with the old sequence. Inserts no longer need them, but a role that calls &lt;code&gt;currval('invoices_id_seq')&lt;/code&gt; gets &lt;code&gt;permission denied for sequence invoices_id_seq&lt;/code&gt; until you grant it again. &lt;code&gt;INSERT ... RETURNING id&lt;/code&gt; avoids &lt;code&gt;currval&lt;/code&gt; altogether.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writers that supply ids.&lt;/strong&gt; Seed scripts and fixtures that insert explicit ids fail against &lt;code&gt;ALWAYS&lt;/code&gt;. Add &lt;code&gt;OVERRIDING SYSTEM VALUE&lt;/code&gt; to them, or convert to &lt;code&gt;BY DEFAULT&lt;/code&gt; instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Converting does not change the column's type, so an &lt;code&gt;integer&lt;/code&gt; key converted to identity still overflows at 2,147,483,647. If the table is heading there, widen it separately, and plan for the rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity handles serial and identity keys
&lt;/h2&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. When you &lt;a href="https://schemity.com/doc/connect-postgresql/" rel="noopener noreferrer"&gt;connect to PostgreSQL&lt;/a&gt;, each column's default is drawn on the canvas, so every &lt;code&gt;serial&lt;/code&gt; column shows &lt;code&gt;nextval&lt;/code&gt; beside it and an identity column shows nothing. That makes the legacy keys easy to see, and the bug above easier still: a &lt;code&gt;nextval&lt;/code&gt; on a foreign key column is a &lt;code&gt;serial&lt;/code&gt; that should have been an &lt;code&gt;integer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4yn3cau8jtew8xhnext2.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%2F4yn3cau8jtew8xhnext2.webp" alt="Schemity's canvas reading the orders and users tables from PostgreSQL: users.id is INTEGER with the default nextval, orders.user_id is an INTEGER foreign key that also shows nextval, orders.id is a BIGINT identity key with no default shown, and orders.note is TEXT with a NULL default and the N marker for a nullable column" width="800" height="263"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you design the next one, a table drawn with a single integer primary key and no default is created as an identity column. The planned SQL reads &lt;code&gt;"id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/code&gt;, or &lt;code&gt;BIGINT&lt;/code&gt; with a &lt;code&gt;bigint&lt;/code&gt; key. Drawing a &lt;a href="https://schemity.com/doc/relationships/" rel="noopener noreferrer"&gt;relationship&lt;/a&gt; never copies the parent key's default into the new foreign key column, and a parent typed &lt;code&gt;SERIAL&lt;/code&gt; or &lt;code&gt;BIGSERIAL&lt;/code&gt; in a design gives the column &lt;code&gt;INTEGER&lt;/code&gt; or &lt;code&gt;BIGINT&lt;/code&gt;, so the diagram does not reproduce the self-filling foreign key.&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%2F0u3krpkr3faszhs4rvi1.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%2F0u3krpkr3faszhs4rvi1.webp" alt="Schemity's canvas with a new contacts table drafted beside the live orders and users tables, drawn with a dashed border because it is not yet in the database: the relation from users to contacts gave contacts.user_id the type INTEGER with no default and no N marker, while orders.user_id, read from the database, still shows INTEGER with nextval" width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Widening a &lt;code&gt;serial&lt;/code&gt; key in the diagram from &lt;code&gt;INTEGER&lt;/code&gt; to &lt;code&gt;BIGINT&lt;/code&gt; plans the &lt;code&gt;ALTER COLUMN ... TYPE BIGINT&lt;/code&gt; and, after it, &lt;code&gt;ALTER SEQUENCE users_id_seq AS BIGINT&lt;/code&gt;, so the counter is widened in the same migration as the column. &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;Impact analysis&lt;/a&gt; reports the change before anything runs: the table is rewritten, reads and writes are held back while it happens, and the tables whose foreign keys reference the key are listed, since their columns need widening too. Until they are, &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;lint&lt;/a&gt; reports each one as "Foreign key type does not match the column it references".&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%2Fbdmguvoehkizhisbkw2g.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%2Fbdmguvoehkizhisbkw2g.webp" alt="Schemity's Findings view after users.id was changed from INTEGER to BIGINT: on the canvas users.id reads BIGINT with its nextval default kept, and contacts is a new table; the drawer lists the planned changes Table contacts created and Column users.id altered, lint findings that contacts.user_id and orders.user_id no longer match the type they reference, and the impact Rewrites users to change id: ~50K rows, 3.2 MB on disk, holds back reads and writes to users while it reads them, and Changing users.id reaches 2 entities, orders and contacts" width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which to use, in one list
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;New table: &lt;code&gt;bigint GENERATED ALWAYS AS IDENTITY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A loader must write ids: &lt;code&gt;GENERATED BY DEFAULT AS IDENTITY&lt;/code&gt;, or keep &lt;code&gt;ALWAYS&lt;/code&gt; and use &lt;code&gt;OVERRIDING SYSTEM VALUE&lt;/code&gt; in the loader.&lt;/li&gt;
&lt;li&gt;Existing &lt;code&gt;serial&lt;/code&gt; key: convert it in one short transaction, after checking for shared sequences and scripts that insert ids.&lt;/li&gt;
&lt;li&gt;Widening a &lt;code&gt;serial&lt;/code&gt; key to &lt;code&gt;bigint&lt;/code&gt;: widen the sequence too, or convert to identity first.&lt;/li&gt;
&lt;li&gt;Foreign key column: the plain integer type under the parent key, &lt;code&gt;NOT NULL&lt;/code&gt; when the relationship is mandatory, never &lt;code&gt;serial&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whether the key should be a number at all is the question in &lt;a href="https://schemity.com/blog/postgres-uuid-vs-bigint-primary-key/" rel="noopener noreferrer"&gt;UUID vs bigint primary keys in Postgres&lt;/a&gt;. What a new &lt;code&gt;NOT NULL&lt;/code&gt; column with a volatile default costs on a large table is in &lt;a href="https://schemity.com/blog/postgres-add-not-null-column-large-table/" rel="noopener noreferrer"&gt;how to add a NOT NULL column to a large table&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Database MCP Server: Should an AI Agent Run SQL or Only Read the Schema?</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 01 Oct 2026 01:25:24 +0000</pubDate>
      <link>https://dev.to/tbson87/database-mcp-server-should-an-ai-agent-run-sql-or-only-read-the-schema-4nap</link>
      <guid>https://dev.to/tbson87/database-mcp-server-should-an-ai-agent-run-sql-or-only-read-the-schema-4nap</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; For schema work an AI agent needs the structure, not the rows, so give it a database MCP server that exposes the schema and no SQL tool. When a task does need rows, guard it with a database role that owns nothing. A &lt;code&gt;READ ONLY&lt;/code&gt; transaction alone is not a guard: the reference Postgres MCP server relied on one, and a single &lt;code&gt;COMMIT; DROP TABLE&lt;/code&gt; sent as one query ended it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An AI agent doing schema work needs the structure of your database, not its rows, so the safest database MCP server for it is one that exposes the schema and has no SQL tool at all. When a task really does need data, the guard has to be the database's own permissions: a login role that owns nothing. A read-only transaction around the agent's SQL looks like the same guard, and it is not.&lt;/p&gt;

&lt;p&gt;The difference is easy to show. The reference Postgres MCP server, which the Model Context Protocol project published as an example, runs every query inside &lt;code&gt;BEGIN TRANSACTION READ ONLY&lt;/code&gt;. Every test below was run against PostgreSQL 18.3 in a throwaway container, with that tool's handler reproduced on the same &lt;code&gt;pg&lt;/code&gt; driver (version 8.23.0) and a &lt;code&gt;customers&lt;/code&gt; table holding emails and card digits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can a database MCP server let an agent do?
&lt;/h2&gt;

&lt;p&gt;The options differ in what the agent sees and in what stops a write:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the MCP server offers&lt;/th&gt;
&lt;th&gt;What the agent can read&lt;/th&gt;
&lt;th&gt;What stops a write&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A SQL tool inside a &lt;code&gt;READ ONLY&lt;/code&gt; transaction, as your usual login&lt;/td&gt;
&lt;td&gt;Every row&lt;/td&gt;
&lt;td&gt;The transaction flag, which the agent's own SQL can end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A SQL tool, logged in as a role with &lt;code&gt;SELECT&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;Every row it has &lt;code&gt;SELECT&lt;/code&gt; on&lt;/td&gt;
&lt;td&gt;The role's privileges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A SQL tool, logged in as a role with no table grants&lt;/td&gt;
&lt;td&gt;Structure only, from &lt;code&gt;pg_catalog&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The role's privileges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No SQL tool, only schema tools&lt;/td&gt;
&lt;td&gt;Structure, plus whatever counts the tools compute&lt;/td&gt;
&lt;td&gt;There is no statement to write with&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row is the one most "read-only" database MCP servers start from, and it is the only one where the agent can undo the guard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is a read-only Postgres MCP server safe?
&lt;/h2&gt;

&lt;p&gt;Not when the transaction is the only guard. The &lt;a href="https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres" rel="noopener noreferrer"&gt;archived reference server's source&lt;/a&gt; handles a query in three calls: &lt;code&gt;client.query("BEGIN TRANSACTION READ ONLY")&lt;/code&gt;, then &lt;code&gt;client.query(sql)&lt;/code&gt; with the agent's text, then &lt;code&gt;ROLLBACK&lt;/code&gt;. With no parameters, node-postgres sends that text over the simple query protocol, which accepts several statements in one string. Sent through the same three calls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SELECT email, card_last4 FROM customers&lt;/code&gt; returned every customer's email and card digits.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;INSERT INTO customers ...&lt;/code&gt; failed with &lt;code&gt;cannot execute INSERT in a read-only transaction&lt;/code&gt;, as intended.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COMMIT; DROP TABLE customers&lt;/code&gt; returned &lt;code&gt;COMMIT&lt;/code&gt; and &lt;code&gt;DROP&lt;/code&gt;. The &lt;code&gt;COMMIT&lt;/code&gt; ended the read-only transaction, the &lt;code&gt;DROP&lt;/code&gt; ran in autocommit, and the next query failed with &lt;code&gt;relation "customers" does not exist&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not a new finding: Datadog Security Labs described the same escape in &lt;a href="https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/" rel="noopener noreferrer"&gt;"MCP vulnerability case study: SQL injection in the Postgres MCP server"&lt;/a&gt; on August 21, 2025. The repository was archived on May 29, 2025, and its README now says "No security updates or bug fixes will be provided" for these servers. Other Postgres MCP servers are separate code, so read how yours runs the agent's SQL: a prepared statement accepts only one statement, while a raw query string passed through as it arrives accepts several.&lt;/p&gt;

&lt;p&gt;Even without the escape, the first result is the larger problem for most teams. Whatever a tool returns becomes part of the agent's context, so with a cloud-hosted model the emails and card digits are now with the model provider too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a database role is the guard that holds
&lt;/h2&gt;

&lt;p&gt;The database checks privileges on every statement, whatever transaction it runs in, so writes to your tables are refused however the agent's SQL is shaped. The same three calls, logged in as a role with &lt;code&gt;pg_read_all_data&lt;/code&gt; and a default timeout:&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;ROLE&lt;/span&gt; &lt;span class="n"&gt;agent_ro&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'change-me'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="n"&gt;pg_read_all_data&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;agent_ro&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;ROLE&lt;/span&gt; &lt;span class="n"&gt;agent_ro&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'5s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;COMMIT; DROP TABLE customers&lt;/code&gt; failed with &lt;code&gt;must be owner of table customers&lt;/code&gt;, and the table stayed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SELECT pg_sleep(10)&lt;/code&gt; failed after 5 seconds with &lt;code&gt;canceling statement due to statement timeout&lt;/code&gt;, but only because the SQL did not change it. &lt;code&gt;SET LOCAL statement_timeout = 0; SELECT pg_sleep(3)&lt;/code&gt; ran to the end: a role's timeout is a default, and any role can override it. A hard limit has to sit where the agent's SQL cannot reach, in the MCP server or a connection pooler.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SELECT email, card_last4 FROM customers&lt;/code&gt; still returned the rows, because reading them is exactly what this role is for.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COMMIT; CREATE TEMP TABLE t (x int)&lt;/code&gt; succeeded, because &lt;code&gt;PUBLIC&lt;/code&gt; may create temporary tables by default. That touches none of your data; revoke &lt;code&gt;TEMP&lt;/code&gt; on the database if it matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a &lt;code&gt;SELECT&lt;/code&gt;-only role fixes the write escape but not the data exposure. For an agent that works on the schema rather than the data, go one step further: a role with &lt;code&gt;CONNECT&lt;/code&gt; on the database, &lt;code&gt;USAGE&lt;/code&gt; on the schema and no table grants reads the entire structure from &lt;code&gt;pg_catalog&lt;/code&gt; and is refused every row. Only from &lt;code&gt;pg_catalog&lt;/code&gt;, though: &lt;code&gt;information_schema&lt;/code&gt; hides every table the role has no privilege on, so a SQL tool that lists tables through it shows that role an empty database. That setup, and what the catalogue still reveals, is in &lt;a href="https://schemity.com/blog/postgres-role-read-schema-not-data/" rel="noopener noreferrer"&gt;a PostgreSQL role that reads the schema but not the data&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does an agent need to understand your schema?
&lt;/h2&gt;

&lt;p&gt;Most of what an agent is asked to do with a database is structural: explain how tables relate, find where a module's tables are, propose a new table or a column change, or check what a migration will break. None of that needs a row. It needs table and column names, types, nullability, keys, check constraints, indexes and comments, and for judging a change, row estimates and table sizes, which the catalogue holds too.&lt;/p&gt;

&lt;p&gt;That is what to understand your schema means for an agent, and it is also the least sensitive part of the database to send to a model. A diagram of &lt;code&gt;customers&lt;/code&gt; with an &lt;code&gt;email&lt;/code&gt; column tells the model the column exists. A &lt;code&gt;SELECT&lt;/code&gt; tells it every address.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity's database MCP server works
&lt;/h2&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. It is also a local database MCP server with 16 tools, and none of them runs SQL the agent writes: &lt;code&gt;analyze_migration_file&lt;/code&gt; accepts a migration file to analyse, and it is parsed, never executed. The agent reads the schema Schemity already holds and never receives the database credentials. &lt;a href="https://schemity.com/doc/ai-assisted-design/" rel="noopener noreferrer"&gt;Connecting Claude Code&lt;/a&gt; takes one command, and other MCP hosts take a config entry.&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%2Fx95hjsuxyx6pbz6xuxqf.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%2Fx95hjsuxyx6pbz6xuxqf.webp" alt="Schemity's MCP server drawer: Running on http://127.0.0.1:7332/mcp, the server enabled on port 7332, the bearer token hidden behind Reveal, Copy and Regenerate, Test connection reporting Server answered with 16 tools, and under Connect a host the Claude Code command claude mcp add --scope user schemity -- " width="744" height="1396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What the tools give an agent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;get_schema&lt;/code&gt; returns the tables and views with their columns, keys, constraints, indexes and relations. &lt;code&gt;get_dependencies&lt;/code&gt; lists the relations into and out of one table, &lt;code&gt;get_context_views&lt;/code&gt; the domains the tables are grouped into, and &lt;code&gt;get_data_dictionary&lt;/code&gt; the same schema as a document with its descriptions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;propose_changes&lt;/code&gt; edits the diagram. The edits land as unsaved changes you review, and nothing is written to the database.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;analyze_impact&lt;/code&gt; reports what the pending migration would cost, and returns the SQL only when asked, with a parameter description telling the agent that Schemity never runs it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;count_rows&lt;/code&gt; returns one number, such as how many rows of a column are &lt;code&gt;NULL&lt;/code&gt; before it becomes &lt;code&gt;NOT NULL&lt;/code&gt;. It takes a probe object rather than SQL and refuses the one kind of probe built from free text, so its query is assembled from quoted table and column names with no text from the agent in it. That, not the transaction, is what keeps agent SQL out; the read-only transaction and the 60-second timeout it also runs under on PostgreSQL are set by Schemity, where the agent cannot change them. It is a full table scan, so ask your agent to run it only when you want the number.&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%2F3xgnd5zcwbqy4uj9w5wu.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%2F3xgnd5zcwbqy4uj9w5wu.webp" alt="A Claude Code session asking which tables reference orders and what check constraints customers has, answered through three calls to the Schemity MCP server against a shop diagram connected to a live PostgreSQL database: a table of the 11 tables that reference orders.id with their column and nullability, customers listed with its columns and one index and no check constraints, a note that every relation is inferred from column names because the database declares no foreign keys, and two gaps, unindexed coupon_redemptions.order_id and tickets.order_id and a customers.email that is not unique" width="800" height="1043"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every transport listens on &lt;code&gt;127.0.0.1&lt;/code&gt; only and needs a token, and the HTTP one also checks the request's origin. Applying a migration stays a human action in the app. Connect the diagram with the no-grants role from the post above and &lt;code&gt;count_rows&lt;/code&gt; is refused too, which leaves the agent with nothing but structure.&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%2Fp95uzz52k0t465jg8549.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%2Fp95uzz52k0t465jg8549.webp" alt="A Claude Code session asking how many rows customers has with count_rows, on a shop diagram connected to PostgreSQL as a role with no table grants: Schemity ran count_rows on customers and the database refused it with permission denied for table customers" width="800" height="178"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which database MCP setup to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Designing, reviewing or documenting a schema: a schema-only MCP server, or a SQL one logged in as a role with no table grants.&lt;/li&gt;
&lt;li&gt;Answering questions about the data: a SQL server logged in as a role with &lt;code&gt;SELECT&lt;/code&gt; on the tables it needs and owning nothing, a time limit the agent cannot unset, and only where sending those rows to your model provider is acceptable.&lt;/li&gt;
&lt;li&gt;Never: your application's login, an owner role, or a &lt;code&gt;READ ONLY&lt;/code&gt; transaction as the only protection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For what the agent should be allowed to change once it has read the schema, see &lt;a href="https://schemity.com/blog/should-ai-agents-write-database-migrations/" rel="noopener noreferrer"&gt;should AI agents write database migrations&lt;/a&gt;. Using the agent to sort a large legacy schema into domains is covered in &lt;a href="https://schemity.com/blog/reverse-engineer-legacy-database-group-by-domain/" rel="noopener noreferrer"&gt;grouping a legacy database by domain&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>postgres</category>
      <category>database</category>
      <category>security</category>
    </item>
    <item>
      <title>Changing a Column Type in SQLite: The Table Rebuild and What It Deletes</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 01 Oct 2026 01:24:38 +0000</pubDate>
      <link>https://dev.to/tbson87/changing-a-column-type-in-sqlite-the-table-rebuild-and-what-it-deletes-do1</link>
      <guid>https://dev.to/tbson87/changing-a-column-type-in-sqlite-the-table-rebuild-and-what-it-deletes-do1</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; SQLite cannot change a column's type in place, so you copy the table into a new one with the right type, drop the old one and rename. Done carelessly, that rebuild deletes child rows through &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; when &lt;code&gt;PRAGMA foreign_keys = OFF&lt;/code&gt; is run inside the transaction, drops every trigger and index on the table, and leaves values that do not convert stored as text in the new numeric column.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;SQLite cannot change a column's type with &lt;code&gt;ALTER TABLE&lt;/code&gt;. The only way is a table rebuild: create a new table with the column typed the way you want, copy the rows across, drop the old table and rename the new one into its place. It takes four statements, and done in the obvious order it can delete rows from other tables, drop triggers and indexes, and leave some values unconverted without an error.&lt;/p&gt;

&lt;p&gt;Every statement below was run with the &lt;code&gt;sqlite3&lt;/code&gt; shell that ships with macOS, Apple's own build of SQLite, which reports version 3.54.0 although the newest release on sqlite.org is 3.53.4, against an &lt;code&gt;orders&lt;/code&gt; table whose &lt;code&gt;total&lt;/code&gt; was created as &lt;code&gt;TEXT&lt;/code&gt; and needs to become &lt;code&gt;REAL&lt;/code&gt;, with an &lt;code&gt;order_items&lt;/code&gt; child table, an index, a trigger and a view on 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="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;foreign_keys&lt;/span&gt; &lt;span class="o"&gt;=&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;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;INTEGER&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;total&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;updated_at&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;order_items&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;INTEGER&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="nb"&gt;INTEGER&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;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="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;sku&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;orders_updated_at_idx&lt;/span&gt; &lt;span class="k"&gt;ON&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;updated_at&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;orders_touch&lt;/span&gt; &lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;NEW&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;END&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;VIEW&lt;/span&gt; &lt;span class="n"&gt;big_orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&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="n"&gt;total&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;'150.00'&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;'19.90'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'n/a'&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;order_items&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="n"&gt;sku&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;'A'&lt;/span&gt;&lt;span class="p"&gt;),&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;'B'&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;'C'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which ALTER TABLE changes SQLite can do in place
&lt;/h2&gt;

&lt;p&gt;Before rebuilding, check whether you need to. The &lt;a href="https://www.sqlite.org/lang_altertable.html" rel="noopener noreferrer"&gt;SQLite &lt;code&gt;ALTER TABLE&lt;/code&gt; documentation&lt;/a&gt; lists what it does directly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;In place?&lt;/th&gt;
&lt;th&gt;Since&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rename a table or a column&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;3.25.0 for columns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add a column&lt;/td&gt;
&lt;td&gt;Yes, with restrictions on defaults and constraints&lt;/td&gt;
&lt;td&gt;3.2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drop a column&lt;/td&gt;
&lt;td&gt;Yes, unless it is in a primary key, unique constraint, index or foreign key, or used by a check, generated column, view or trigger&lt;/td&gt;
&lt;td&gt;3.35.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set or drop &lt;code&gt;NOT NULL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;ALTER TABLE ... ALTER COLUMN ... SET NOT NULL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;3.53.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add a &lt;code&gt;CHECK&lt;/code&gt; constraint, or drop a named one&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;ALTER TABLE ... ADD CONSTRAINT ... CHECK (...)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;3.53.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change a column's type&lt;/td&gt;
&lt;td&gt;No, rebuild the table&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add or change a foreign key, primary key or unique constraint&lt;/td&gt;
&lt;td&gt;No, rebuild the table&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 3.53.0 &lt;a href="https://www.sqlite.org/changes.html" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; describe the newest addition as permitting "adding and removing NOT NULL and CHECK constraints". Both check the existing rows: &lt;code&gt;SET NOT NULL&lt;/code&gt; on a column with a &lt;code&gt;NULL&lt;/code&gt; failed with &lt;code&gt;constraint failed&lt;/code&gt;, and so did adding &lt;code&gt;CHECK (total &amp;gt; 0)&lt;/code&gt; to a table with a negative total. Anything older than 3.53.0, including the SQLite your driver may bundle, still needs a rebuild for those two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things a SQLite table rebuild deletes, and one it gets wrong
&lt;/h2&gt;

&lt;p&gt;Here is the rebuild as it is usually written, run in the same session as the setup so foreign keys are on, with the switch to turn them off placed inside the 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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;foreign_keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;OFF&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__new&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;INTEGER&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;total&lt;/span&gt; &lt;span class="nb"&gt;REAL&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;updated_at&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders__new&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DROP&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="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders__new&lt;/span&gt; &lt;span class="k"&gt;RENAME&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;orders&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It succeeds, and afterwards &lt;code&gt;SELECT count(*) FROM order_items&lt;/code&gt; returns 0. All three order items are gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Child rows, through the cascade.&lt;/strong&gt; The &lt;a href="https://www.sqlite.org/foreignkeys.html" rel="noopener noreferrer"&gt;foreign key documentation&lt;/a&gt; explains both halves. With enforcement on, &lt;code&gt;DROP TABLE&lt;/code&gt; "performs an implicit DELETE to remove all rows from the table before dropping it", which "may invoke foreign key actions", and &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; is one. The &lt;code&gt;PRAGMA&lt;/code&gt; that was meant to prevent it did nothing, because changing foreign key enforcement inside a transaction "does not return an error; it simply has no effect". Running &lt;code&gt;PRAGMA foreign_keys;&lt;/code&gt; inside the transaction still printed &lt;code&gt;1&lt;/code&gt;. Without a cascade the same drop fails with &lt;code&gt;FOREIGN KEY constraint failed&lt;/code&gt; instead, which is the safer way to find out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every trigger and index on the table.&lt;/strong&gt; After the rebuild, &lt;code&gt;sqlite_schema&lt;/code&gt; lists the view and both tables, and no longer lists &lt;code&gt;orders_touch&lt;/code&gt; or &lt;code&gt;orders_updated_at_idx&lt;/code&gt;. &lt;code&gt;DROP TABLE&lt;/code&gt; removes what is attached to the table, and nothing in the rebuild puts them back. The missing index shows up as a slow query. The missing trigger shows up as an &lt;code&gt;updated_at&lt;/code&gt; that stops changing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Values that do not convert.&lt;/strong&gt; The copy did not fail on &lt;code&gt;'n/a'&lt;/code&gt;. &lt;code&gt;REAL&lt;/code&gt; in SQLite is a type affinity, not a check, and the &lt;a href="https://www.sqlite.org/datatype3.html" rel="noopener noreferrer"&gt;datatype documentation&lt;/a&gt; says a text value that is not a well-formed number "is stored as TEXT". The new column holds &lt;code&gt;150.0&lt;/code&gt; and &lt;code&gt;19.9&lt;/code&gt; as real numbers and &lt;code&gt;'n/a'&lt;/code&gt; as text. Before the rebuild, &lt;code&gt;big_orders&lt;/code&gt; compared text with text and returned all three orders, &lt;code&gt;19.90&lt;/code&gt; included. After it, &lt;code&gt;19.90&lt;/code&gt; correctly drops out, but order 3 stays in the view as a big order, because SQLite sorts any text above any number. A table declared &lt;code&gt;STRICT&lt;/code&gt; would have refused the value with &lt;code&gt;cannot store TEXT value in REAL column&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you change a column type in SQLite?
&lt;/h2&gt;

&lt;p&gt;Follow the 12-step procedure from the &lt;code&gt;ALTER TABLE&lt;/code&gt; documentation. For this table it is:&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="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;foreign_keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;OFF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&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;type&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;sql&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sqlite_schema&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tbl_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'table'&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__new&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;INTEGER&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;total&lt;/span&gt; &lt;span class="nb"&gt;REAL&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;updated_at&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders__new&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="n"&gt;total&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders__new&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'real'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- stop here if that returned rows: ROLLBACK, fix them, start again&lt;/span&gt;
&lt;span class="k"&gt;DROP&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="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders__new&lt;/span&gt; &lt;span class="k"&gt;RENAME&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;orders&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;orders_updated_at_idx&lt;/span&gt; &lt;span class="k"&gt;ON&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;updated_at&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;orders_touch&lt;/span&gt; &lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;NEW&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;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;foreign_key_check&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="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;foreign_keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each addition does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PRAGMA foreign_keys = OFF&lt;/code&gt; comes before &lt;code&gt;BEGIN&lt;/code&gt;, so it takes effect and the drop cascades into nothing. All three order items survived.&lt;/li&gt;
&lt;li&gt;The first &lt;code&gt;SELECT&lt;/code&gt; prints the &lt;code&gt;CREATE INDEX&lt;/code&gt; and &lt;code&gt;CREATE TRIGGER&lt;/code&gt; statements to run again after the rename.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;typeof&lt;/code&gt; check lists rows whose value did not convert. Here it returned &lt;code&gt;3|n/a&lt;/code&gt;. A script pasted in one go does not stop there, so run the steps one at a time, and if the check returns anything, &lt;code&gt;ROLLBACK&lt;/code&gt;, fix those rows, and start again.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PRAGMA foreign_key_check&lt;/code&gt; lists rows that break a foreign key. With enforcement off, nothing else would.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The view needed no attention. It names &lt;code&gt;orders&lt;/code&gt;, and after the rename &lt;code&gt;orders&lt;/code&gt; exists again. A view that selects a column you renamed or dropped does need recreating, as step 9 of the documented procedure says.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rebuild step&lt;/th&gt;
&lt;th&gt;Written the usual way&lt;/th&gt;
&lt;th&gt;Written the safe way&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PRAGMA foreign_keys = OFF&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inside the transaction, ignored&lt;/td&gt;
&lt;td&gt;Before &lt;code&gt;BEGIN&lt;/code&gt;, applied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rows in &lt;code&gt;order_items&lt;/code&gt; afterwards&lt;/td&gt;
&lt;td&gt;0 of 3&lt;/td&gt;
&lt;td&gt;3 of 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index and trigger on &lt;code&gt;orders&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Gone&lt;/td&gt;
&lt;td&gt;Recreated from &lt;code&gt;sqlite_schema&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;'n/a'&lt;/code&gt; in the &lt;code&gt;REAL&lt;/code&gt; column&lt;/td&gt;
&lt;td&gt;Copied as text, no error&lt;/td&gt;
&lt;td&gt;Listed by the &lt;code&gt;typeof&lt;/code&gt; check, so you can stop before the drop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How Schemity plans a SQLite rebuild
&lt;/h2&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 SQLite connection it alters directly for adding, renaming and dropping columns, and plans a rebuild for the rest: a type, nullability or default change, and adding or dropping a primary key, unique, check or foreign key constraint. That includes the &lt;code&gt;NOT NULL&lt;/code&gt; and &lt;code&gt;CHECK&lt;/code&gt; changes SQLite 3.53 can make in place.&lt;/p&gt;

&lt;p&gt;Its rebuild follows the documented order. &lt;code&gt;PRAGMA foreign_keys = OFF&lt;/code&gt; runs on the connection before the transaction starts, &lt;code&gt;PRAGMA foreign_key_check&lt;/code&gt; runs before the commit and rolls the whole migration back if any row breaks a key, and foreign keys are switched back on whether the migration succeeded or not. The rebuilt table gets its indexes and its triggers back, keeps &lt;code&gt;AUTOINCREMENT&lt;/code&gt; only if the table was declared with it, and computes generated columns again rather than copying them, since SQLite refuses an insert into one. To see the impact of every change before it runs, &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; reports this one as "Changes the type of orders.total, values may not survive the cast", which is the &lt;code&gt;'n/a'&lt;/code&gt; case, and "Rewrites orders to change total", with a row count when the database has been analysed with &lt;code&gt;ANALYZE&lt;/code&gt;. It also lists &lt;code&gt;orders_touch&lt;/code&gt; as a trigger that depends on &lt;code&gt;orders&lt;/code&gt;, and &lt;code&gt;big_orders&lt;/code&gt; as a view that mentions 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%2Ff4pfnqxkw28eejjiidno.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%2Ff4pfnqxkw28eejjiidno.webp" alt="Schemity's Impact drawer for changing orders.total from TEXT to REAL on a SQLite connection: under Loses data, Changes the type of orders.total, values may not survive the cast: ~3 rows, with Count exactly; under Rewrites the table, Rewrites orders to change total: ~3 rows; under Dependent objects, 2 objects depend on orders.total, trigger orders_touch depends on orders and view big_orders mentions orders in its body; the canvas shows orders with total as REAL, order_items and the big_orders view" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The triggers come back from their own SQL, as read from &lt;code&gt;sqlite_schema&lt;/code&gt;, the same way the script above recreates &lt;code&gt;orders_touch&lt;/code&gt;. SQLite does not check a trigger's body when the trigger is created, so a trigger that names a column the same change renames or drops would be created and then fail when it fires. Schemity refuses that save instead, naming the table and the column, so the trigger can be changed first. The &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL diff&lt;/a&gt; shows every statement of the planned rebuild, the recreated trigger included, before you apply 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%2Fyz41v2w84eijxe05p2qf.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%2Fyz41v2w84eijxe05p2qf.webp" alt="Schemity's change preview for changing orders.total from TEXT to REAL on SQLite: the planned migration runs PRAGMA foreign_keys=OFF, CREATE TABLE " width="800" height="645"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check before any SQLite rebuild
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Your SQLite version: on 3.53.0 or later, &lt;code&gt;NOT NULL&lt;/code&gt; and &lt;code&gt;CHECK&lt;/code&gt; changes no longer need a rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PRAGMA foreign_keys = OFF&lt;/code&gt; before &lt;code&gt;BEGIN&lt;/code&gt;, never after it.&lt;/li&gt;
&lt;li&gt;The table's triggers and indexes, saved from &lt;code&gt;sqlite_schema&lt;/code&gt; and recreated after the rename.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;typeof&lt;/code&gt; check on the new table before the old one is dropped.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PRAGMA foreign_key_check&lt;/code&gt; before &lt;code&gt;COMMIT&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Schema changes on PostgreSQL have their own traps, and the ones that lock or rewrite a large table are 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 Postgres table&lt;/a&gt;. What &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; reaches, and why a diagram should show it, is in &lt;a href="https://schemity.com/blog/on-delete-cascade-is-invisible-in-your-erd/" rel="noopener noreferrer"&gt;ON DELETE CASCADE is invisible in your ERD&lt;/a&gt;. For a migration written by an ORM or an AI agent rather than by hand, see &lt;a href="https://schemity.com/blog/should-ai-agents-write-database-migrations/" rel="noopener noreferrer"&gt;should AI agents write database migrations&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>database</category>
      <category>sql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>ON DELETE SET NULL vs CASCADE vs RESTRICT in PostgreSQL: Which to Use</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 01 Oct 2026 01:24:01 +0000</pubDate>
      <link>https://dev.to/tbson87/on-delete-set-null-vs-cascade-vs-restrict-in-postgresql-which-to-use-1n6l</link>
      <guid>https://dev.to/tbson87/on-delete-set-null-vs-cascade-vs-restrict-in-postgresql-which-to-use-1n6l</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; Pick the action by what the child row means once its parent is gone: &lt;code&gt;CASCADE&lt;/code&gt; when it means nothing, &lt;code&gt;SET NULL&lt;/code&gt; when it stays valid with the link removed, and &lt;code&gt;RESTRICT&lt;/code&gt; or &lt;code&gt;NO ACTION&lt;/code&gt; when it is a record that must not change behind anyone's back. &lt;code&gt;SET NULL&lt;/code&gt; is the one PostgreSQL accepts and then refuses at delete time, if the column is &lt;code&gt;NOT NULL&lt;/code&gt;, a check needs the value, or the key is composite and nulls a tenant column along with it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A foreign key's &lt;code&gt;ON DELETE&lt;/code&gt; action should follow from one question: what does the child row mean once its parent is gone? If it means nothing, &lt;code&gt;CASCADE&lt;/code&gt; deletes it with the parent. If it is still a valid row with the link removed, &lt;code&gt;SET NULL&lt;/code&gt; keeps it. If it is a record someone relies on, &lt;code&gt;RESTRICT&lt;/code&gt; refuses the delete and makes you decide.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SET NULL&lt;/code&gt; needs the most care, because PostgreSQL accepts it in cases where it can never succeed and only says so when someone deletes a parent row in production. Every statement below was run on PostgreSQL 18.3 in a throwaway container, with a support desk schema: &lt;code&gt;agents&lt;/code&gt; as the parent and &lt;code&gt;tickets&lt;/code&gt; as the child, and for the timing tests 10,000 &lt;code&gt;users&lt;/code&gt; and 2,000,000 &lt;code&gt;tickets&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  ON DELETE SET NULL vs CASCADE: which should you use?
&lt;/h2&gt;

&lt;p&gt;Decide per foreign key, from the child's point of view:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;After the parent row is deleted, the child row is&lt;/th&gt;
&lt;th&gt;Action&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;Meaningless&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CASCADE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Order lines of an order, sessions of a user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Still valid, with the link removed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SET NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A ticket whose assignee left, a post whose editor's account was deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A record that must not change silently&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;RESTRICT&lt;/code&gt; or &lt;code&gt;NO ACTION&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;An invoice for a customer, a payment for an order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Still valid, pointing at a known fallback row&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SET DEFAULT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tickets moved to an "unassigned" queue row&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; fits when &lt;code&gt;NULL&lt;/code&gt; has an honest meaning in that column, such as "nobody is assigned". It is the wrong choice when &lt;code&gt;NULL&lt;/code&gt; would erase something the business needs, such as who handled a ticket that later goes to an audit. There, keep the parent row, mark it inactive, and let &lt;code&gt;RESTRICT&lt;/code&gt; stop the delete.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RESTRICT&lt;/code&gt; and &lt;code&gt;NO ACTION&lt;/code&gt; both refuse a delete that would leave orphans. &lt;code&gt;NO ACTION&lt;/code&gt; is the default, and the PostgreSQL documentation on constraints gives the one difference: &lt;code&gt;RESTRICT&lt;/code&gt; "does not allow the check to be deferred until later in the transaction". On a key declared &lt;code&gt;DEFERRABLE INITIALLY DEFERRED&lt;/code&gt;, or deferred with &lt;code&gt;SET CONSTRAINTS ALL DEFERRED&lt;/code&gt;, &lt;code&gt;NO ACTION&lt;/code&gt; lets the same transaction insert a replacement parent or delete the dangling children before the check runs at commit. &lt;code&gt;DEFERRABLE&lt;/code&gt; alone is not enough: the check is still immediate, and the delete fails on the spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways SET NULL is accepted and then fails
&lt;/h2&gt;

&lt;p&gt;PostgreSQL checks that the referenced columns exist and are unique when you create the key. It does not check that the child column can hold &lt;code&gt;NULL&lt;/code&gt;. The failure arrives with the first delete of a parent that has children.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The column is &lt;code&gt;NOT NULL&lt;/code&gt;.&lt;/strong&gt; This key is created without complaint:&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;tickets&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="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;assignee_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;agents&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="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deleting an agent who has a ticket then fails with &lt;code&gt;ERROR: null value in column "assignee_id" of relation "tickets" violates not-null constraint&lt;/code&gt;, and the agent stays. The other engines refuse the definition instead, with one catch on MySQL. MySQL 8.4 parses an inline &lt;code&gt;REFERENCES&lt;/code&gt; like the one above and ignores it, so that &lt;code&gt;CREATE TABLE&lt;/code&gt; succeeds with no foreign key at all. Written as a table-level &lt;code&gt;FOREIGN KEY (assignee_id) REFERENCES agents (id) ON DELETE SET NULL&lt;/code&gt;, it is rejected with &lt;code&gt;ERROR 1830 (HY000): Column 'assignee_id' cannot be NOT NULL: needed in a foreign key constraint 'tickets_ibfk_1' SET NULL&lt;/code&gt;. SQL Server 2022 rejects it with &lt;code&gt;Msg 1761: Cannot create the foreign key ... with the SET NULL referential action, because one or more referencing columns are not nullable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A check constraint needs the value.&lt;/strong&gt; A ticket in the &lt;code&gt;assigned&lt;/code&gt; status must have an assignee:&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;tickets&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="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;status&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;assignee_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;agents&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="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'assigned'&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;assignee_id&lt;/span&gt; &lt;span class="k"&gt;IS&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The column is nullable, so the key looks fine, but deleting the agent of an assigned ticket fails with &lt;code&gt;new row for relation "tickets" violates check constraint "tickets_check"&lt;/code&gt;. &lt;code&gt;SET NULL&lt;/code&gt; writes a new version of the child row, and every check on that row runs again. The fix is a decision rather than a syntax change: either the check allows it, or the application reassigns the tickets before the agent goes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key is composite.&lt;/strong&gt; In a multi-tenant schema the key usually includes the tenant, so a ticket cannot point at another tenant's agent:&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;tickets&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tenant_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="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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;assignee_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&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;tenant_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;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;tenant_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;agents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_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="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain &lt;code&gt;SET NULL&lt;/code&gt; sets every column of the key, so the delete fails with &lt;code&gt;null value in column "tenant_id" of relation "tickets" violates not-null constraint&lt;/code&gt;. Since PostgreSQL 15, whose &lt;a href="https://www.postgresql.org/docs/release/15.0/" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; describe the change as letting &lt;code&gt;ON DELETE SET&lt;/code&gt; actions "affect only specified columns", you can name the column:&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;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;tenant_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;agents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_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="k"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that key the same delete succeeds, and the ticket keeps &lt;code&gt;tenant_id = 7&lt;/code&gt; with &lt;code&gt;assignee_id&lt;/code&gt; empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SET NULL does besides writing NULL
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SET NULL&lt;/code&gt; is an &lt;code&gt;UPDATE&lt;/code&gt; of every child row, not a quiet unlink. A &lt;code&gt;BEFORE UPDATE&lt;/code&gt; trigger on &lt;code&gt;tickets&lt;/code&gt; that stamps &lt;code&gt;updated_at&lt;/code&gt; fired when the agent was deleted and moved the ticket's &lt;code&gt;updated_at&lt;/code&gt; to the time of the delete, so a "recently changed" list will show every ticket the agent had. Each updated row is a new row version, with the same vacuum work as any other update.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SET DEFAULT&lt;/code&gt; has its own trap: the default has to exist in the parent. With &lt;code&gt;assignee_id bigint DEFAULT 0&lt;/code&gt; and no agent &lt;code&gt;0&lt;/code&gt;, deleting an agent failed with &lt;code&gt;insert or update on table "tickets" violates foreign key constraint&lt;/code&gt;, because the fallback row the design assumed was never inserted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does ON DELETE need an index on the child column?
&lt;/h2&gt;

&lt;p&gt;For correctness, no. For speed, yes, and it matters for &lt;code&gt;SET NULL&lt;/code&gt;, &lt;code&gt;CASCADE&lt;/code&gt; and &lt;code&gt;RESTRICT&lt;/code&gt; alike, because each has to find the children of the deleted parent. PostgreSQL does not create that index for you. With 2,000,000 tickets across 10,000 users:&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;No index on &lt;code&gt;assignee_id&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;With an index&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One user&lt;/td&gt;
&lt;td&gt;71 ms&lt;/td&gt;
&lt;td&gt;1.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 users in one statement&lt;/td&gt;
&lt;td&gt;7,489 ms&lt;/td&gt;
&lt;td&gt;98 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Without the index, each deleted user costs one full scan of &lt;code&gt;tickets&lt;/code&gt;, 158 MB with its primary key, so a cleanup job that deletes 1,000 users scans the table 1,000 times however few tickets they had.&lt;/p&gt;

&lt;p&gt;Changing the action later is not an &lt;code&gt;ALTER&lt;/code&gt;. &lt;code&gt;ALTER TABLE ... ALTER CONSTRAINT&lt;/code&gt; does not accept &lt;code&gt;ON DELETE&lt;/code&gt;, so the key is dropped and added again, and adding it checks every existing row. On the 2,000,000-row table that re-check took 300 ms while holding back writes to both &lt;code&gt;tickets&lt;/code&gt; and &lt;code&gt;users&lt;/code&gt;. On a large table, drop the old key and add the new one &lt;code&gt;NOT VALID&lt;/code&gt; in the same transaction, so there is no moment without a key (0.9 ms), then run &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; separately (243 ms here), which lets writes carry on.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity handles ON DELETE SET NULL
&lt;/h2&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. When you design the next one, the delete behaviour is part of the &lt;a href="https://schemity.com/doc/relationships/" rel="noopener noreferrer"&gt;relationship&lt;/a&gt;: &lt;code&gt;ON DELETE&lt;/code&gt; and &lt;code&gt;ON UPDATE&lt;/code&gt; are set in the relation dialog, and choosing &lt;code&gt;SET NULL&lt;/code&gt; there makes the foreign key's columns nullable in the same edit and draws the relationship as optional. A primary key column cannot become nullable, so it is left alone.&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%2F4xtvmnflwtv81edrklri.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%2F4xtvmnflwtv81edrklri.webp" alt="Schemity's canvas after switching the tickets to agents relation to ON DELETE SET NULL, not yet migrated: tickets.assignee_id now carries the N marker for a nullable column, the relation line is drawn optional at the agents end, and the Lint drawer shows one finding, Foreign key has no index on its columns, with the SET NULL on a column that is NOT NULL finding gone" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;lint rules&lt;/a&gt; cover what the dialog cannot fix on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SET NULL&lt;/code&gt; on a column that is &lt;code&gt;NOT NULL&lt;/code&gt;.&lt;/strong&gt; Reported for a key read from a database or imported from SQL, on every engine: PostgreSQL accepts the key and fails the delete, MySQL and SQL Server refuse the key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreign key has no index on its columns.&lt;/strong&gt; Reported for &lt;code&gt;tickets.assignee_id&lt;/code&gt; above, since every parent delete scans the child without it.&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%2Fo7jf0rpiafxvto556fo8.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%2Fo7jf0rpiafxvto556fo8.webp" alt="Schemity's Lint drawer for a PostgreSQL 18.3 diagram with tickets and agents: under Fails at runtime, tickets.assignee_id on tickets_assignee_id_fkey reads SET NULL on a column that is NOT NULL, with the detail ON DELETE SET NULL would write NULL into tickets.assignee_id, which is NOT NULL. The constraint is accepted, and the statement on agents fails when it runs; under Costs, the same column reads Foreign key has no index on its columns, with the detail that every delete or key update in agents scans tickets; the assignee_id row is marked on the canvas" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Changing the action on a connected diagram plans the drop and the re-add, and &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; reports it before anything runs: "Drops and recreates a foreign key on tickets", and "Checking the new key holds back writes to tickets while it reads ~200K rows, 16 MB on disk". The finding names the child table only; on PostgreSQL the referenced table waits too. The planned migration is the plain drop and add, run in one transaction, not the &lt;code&gt;NOT VALID&lt;/code&gt; version, so on a large table write that one yourself.&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%2Fon21imcbym8xubrygla8.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%2Fon21imcbym8xubrygla8.webp" alt="Schemity's Migration confirm dialog for changing ON DELETE on tickets_assignee_id_fkey to NO ACTION: under Holds back other statements, Checking the new key holds back writes to tickets while it reads ~200K rows, 16 MB on disk; under Spreads through the diagram, Drops and recreates a foreign key on tickets; and the SQL ALTER TABLE " width="799" height="558"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The current release stores the action without a column list, so a PostgreSQL 15 key such as &lt;code&gt;ON DELETE SET NULL (assignee_id)&lt;/code&gt; reads back as plain &lt;code&gt;SET NULL&lt;/code&gt;, and lint reports its &lt;code&gt;tenant_id&lt;/code&gt; column as &lt;code&gt;NOT NULL&lt;/code&gt;. For that key, keep the column-list definition in your migration file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which ON DELETE action to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The child has no meaning without the parent: &lt;code&gt;CASCADE&lt;/code&gt;, and index the foreign key.&lt;/li&gt;
&lt;li&gt;The child stays valid without the link: &lt;code&gt;SET NULL&lt;/code&gt;, on a nullable column, with no check that requires the value, and with a column list if the key includes a tenant column.&lt;/li&gt;
&lt;li&gt;The child is a record people rely on: &lt;code&gt;RESTRICT&lt;/code&gt; or &lt;code&gt;NO ACTION&lt;/code&gt;, and deactivate the parent instead of deleting it.&lt;/li&gt;
&lt;li&gt;A known fallback row exists: &lt;code&gt;SET DEFAULT&lt;/code&gt;, after inserting that row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reach of a cascade across several tables, and why MySQL does not log it, is covered in &lt;a href="https://schemity.com/blog/on-delete-cascade-is-invisible-in-your-erd/" rel="noopener noreferrer"&gt;ON DELETE CASCADE is invisible in your ERD&lt;/a&gt;. Whether to declare the foreign key at all is the question in &lt;a href="https://schemity.com/blog/should-you-use-foreign-key-constraints/" rel="noopener noreferrer"&gt;should you use foreign key constraints&lt;/a&gt;, and the way a composite key with a &lt;code&gt;NULL&lt;/code&gt; column stops being checked is in &lt;a href="https://schemity.com/blog/unique-constraints-and-nullable-columns/" rel="noopener noreferrer"&gt;unique constraints and nullable columns&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A PostgreSQL Role That Reads the Schema but Not the Data: What to Grant</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 01 Oct 2026 01:23:26 +0000</pubDate>
      <link>https://dev.to/tbson87/a-postgresql-role-that-reads-the-schema-but-not-the-data-what-to-grant-20ep</link>
      <guid>https://dev.to/tbson87/a-postgresql-role-that-reads-the-schema-but-not-the-data-what-to-grant-20ep</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; Grant &lt;code&gt;CONNECT&lt;/code&gt; on the database and &lt;code&gt;USAGE&lt;/code&gt; on the schema, and nothing on the tables: the role can then read the whole structure from &lt;code&gt;pg_catalog&lt;/code&gt; while every &lt;code&gt;SELECT&lt;/code&gt; on a table fails with &lt;code&gt;permission denied&lt;/code&gt;. The catch is that &lt;code&gt;information_schema&lt;/code&gt; hides everything the role has no privilege on, so tools built on it show an empty schema, and the catalogue still shows view definitions, function bodies, comments and row estimates.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A PostgreSQL role can read a database's entire structure without being able to read a single row: grant &lt;code&gt;CONNECT&lt;/code&gt; on the database and &lt;code&gt;USAGE&lt;/code&gt; on the schema, and grant nothing on the tables. Everything a diagram, a data dictionary or a migration review needs is in &lt;code&gt;pg_catalog&lt;/code&gt;, which PostgreSQL lets any connected role read. The rows stay behind &lt;code&gt;permission denied&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That matters because the usual answer to "give the diagram tool a login" is &lt;code&gt;GRANT SELECT ON ALL TABLES&lt;/code&gt;, or the predefined &lt;code&gt;pg_read_all_data&lt;/code&gt; role, and both hand over the customer data along with the schema. Every statement below was run on PostgreSQL 18.3 in a throwaway container, against a &lt;code&gt;shop&lt;/code&gt; database with an &lt;code&gt;app&lt;/code&gt; schema holding 5,000 &lt;code&gt;customers&lt;/code&gt;, 50,000 &lt;code&gt;orders&lt;/code&gt;, a view, a function and an enum.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to give a Postgres user access to the schema but not the data
&lt;/h2&gt;

&lt;p&gt;Three statements, run as a superuser, or as a role with &lt;code&gt;CREATEROLE&lt;/code&gt; that owns the database and the 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;ROLE&lt;/span&gt; &lt;span class="n"&gt;schema_reader&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'change-me'&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;CONNECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;schema_reader&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;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;schema_reader&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;PUBLIC&lt;/code&gt; already has &lt;code&gt;CONNECT&lt;/code&gt; on a new database, so the second statement only matters where it has been revoked, as it is on hardened servers. The third is the one that counts. Connected as &lt;code&gt;schema_reader&lt;/code&gt;, &lt;code&gt;\d app.orders&lt;/code&gt; in psql prints every column, the identity default, the primary key, the foreign key to &lt;code&gt;customers&lt;/code&gt;, and the &lt;code&gt;Referenced by&lt;/code&gt; line for a table added after the grants were made. 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;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;LIMIT&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;-- ERROR:  permission denied for table orders&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because no table is granted anything, there are no default privileges to keep in step. A table created tomorrow shows up in the catalogue for this role at once, and its rows are refused the same way.&lt;/p&gt;

&lt;p&gt;The role cannot change the schema either. &lt;code&gt;ALTER TABLE app.customers ADD COLUMN x int&lt;/code&gt; fails with &lt;code&gt;must be owner of table customers&lt;/code&gt;, because schema changes need ownership, not a grant, and &lt;code&gt;CREATE TABLE app.t (id int)&lt;/code&gt; fails with &lt;code&gt;permission denied for schema app&lt;/code&gt;. One exception to check for: &lt;code&gt;PUBLIC&lt;/code&gt; can execute functions by default, so a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function in the schema runs with its owner's rights. A test function doing &lt;code&gt;SELECT count(*) FROM app.orders&lt;/code&gt; returned 50,000 to this role. Revoke &lt;code&gt;EXECUTE&lt;/code&gt; on such functions from &lt;code&gt;PUBLIC&lt;/code&gt; if they touch data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each grant lets the role see
&lt;/h2&gt;

&lt;p&gt;The same checks, run after each step:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role has&lt;/th&gt;
&lt;th&gt;Tables it can describe in &lt;code&gt;pg_catalog&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Rows in &lt;code&gt;information_schema.columns&lt;/code&gt; for &lt;code&gt;app&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;SELECT&lt;/code&gt; on &lt;code&gt;app.orders&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CONNECT&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;All of them, but &lt;code&gt;SET search_path TO app&lt;/code&gt; resolves to nothing&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;permission denied for schema app&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CONNECT&lt;/code&gt; and &lt;code&gt;USAGE&lt;/code&gt; on &lt;code&gt;app&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;All of them, and after &lt;code&gt;SET search_path TO app&lt;/code&gt;, &lt;code&gt;current_schema()&lt;/code&gt; is &lt;code&gt;app&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;permission denied for table orders&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_read_all_data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All of them&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;50,000 rows&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row is the surprising one. Without &lt;code&gt;USAGE&lt;/code&gt;, the catalogue still lists the schema's tables, since &lt;code&gt;pg_class&lt;/code&gt; is readable by everyone, but the &lt;a href="https://www.postgresql.org/docs/current/runtime-config-client.html" rel="noopener noreferrer"&gt;&lt;code&gt;search_path&lt;/code&gt; documentation&lt;/a&gt; says a schema "for which the user does not have &lt;code&gt;USAGE&lt;/code&gt; permission, is silently ignored". &lt;code&gt;current_schema()&lt;/code&gt; came back &lt;code&gt;NULL&lt;/code&gt;, so any tool that filters its catalogue queries by the current schema reads nothing and reports no error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does information_schema show no tables for a read-only user?
&lt;/h2&gt;

&lt;p&gt;Because the SQL-standard views filter by privilege and the catalogue does not. The &lt;a href="https://www.postgresql.org/docs/current/infoschema-columns.html" rel="noopener noreferrer"&gt;&lt;code&gt;information_schema.columns&lt;/code&gt; page&lt;/a&gt; says it: "Only those columns are shown that the current user has access to (by way of being the owner or having some privilege)." &lt;code&gt;USAGE&lt;/code&gt; on a schema is a privilege on the schema, not on the tables in it, so &lt;code&gt;information_schema.tables&lt;/code&gt;, &lt;code&gt;.columns&lt;/code&gt; and &lt;code&gt;.table_constraints&lt;/code&gt; all returned 0 rows for &lt;code&gt;app&lt;/code&gt;, while &lt;code&gt;pg_attribute&lt;/code&gt; returned all 12 columns and &lt;code&gt;pg_constraint&lt;/code&gt; returned every key and check.&lt;/p&gt;

&lt;p&gt;So the grant is only half the answer. The other half is which catalogue your tool reads. A client built on &lt;code&gt;information_schema&lt;/code&gt; shows this role an empty database, and the tempting fix, a &lt;code&gt;SELECT&lt;/code&gt; grant, is the thing you were trying not to give. psql's &lt;code&gt;\d&lt;/code&gt; reads &lt;code&gt;pg_catalog&lt;/code&gt;, which is why it worked above.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a no-data role can still learn from the catalogue
&lt;/h2&gt;

&lt;p&gt;The structure is not a secret from anyone who can connect, and it carries more than table and column names. As &lt;code&gt;schema_reader&lt;/code&gt;, with no table privileges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check constraints and defaults&lt;/strong&gt;, as written: &lt;code&gt;CHECK (((discount_pct &amp;gt;= (0)::numeric) AND (discount_pct &amp;lt;= (30)::numeric)))&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Column comments&lt;/strong&gt;: "Negotiated discount, capped at 30 by finance".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enum labels&lt;/strong&gt; in order: &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;paid&lt;/code&gt;, &lt;code&gt;shipped&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;View definitions&lt;/strong&gt;, through &lt;code&gt;pg_get_viewdef&lt;/code&gt;, including the business threshold inside one: &lt;code&gt;HAVING (sum(total) &amp;gt; (10000)::numeric)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Function bodies&lt;/strong&gt;, in &lt;code&gt;pg_proc.prosrc&lt;/code&gt;: &lt;code&gt;UPDATE app.orders SET total = total * 0.9 WHERE customer_id = p_customer&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table sizes and row estimates&lt;/strong&gt;: &lt;code&gt;reltuples&lt;/code&gt; read 50,000 for &lt;code&gt;orders&lt;/code&gt; and &lt;code&gt;pg_total_relation_size&lt;/code&gt; read 4,096 kB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it did not get was anything from the rows. &lt;a href="https://www.postgresql.org/docs/current/view-pg-stats.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_stats&lt;/code&gt;&lt;/a&gt; is limited to "rows of &lt;code&gt;pg_statistic&lt;/code&gt; that correspond to tables the user has permission to read", and it returned 0 rows for &lt;code&gt;app&lt;/code&gt;. With &lt;a href="https://www.postgresql.org/docs/current/predefined-roles.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_read_all_data&lt;/code&gt;&lt;/a&gt; granted, the same view returned statistics whose &lt;code&gt;most_common_vals&lt;/code&gt; hold real values from the table, which is one more reason that role is the wrong one for a schema tool. If a view or function body embeds something you would not show this role, such as a customer id or a secret, the fix is to move it out of the definition, because no grant hides it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which credential should the role log in with?
&lt;/h2&gt;

&lt;p&gt;A password for a role that can read production's structure is still a production credential sitting somewhere. On AWS RDS the alternative is IAM database authentication, where the password is a token that, per the &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html" rel="noopener noreferrer"&gt;RDS documentation&lt;/a&gt;, "has a lifetime of 15 minutes" and "is only used for authentication and doesn't affect the session after it is established". The role is created without a password and given the &lt;code&gt;rds_iam&lt;/code&gt; role:&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;USER&lt;/span&gt; &lt;span class="n"&gt;schema_reader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="n"&gt;rds_iam&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;schema_reader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token comes from &lt;code&gt;aws rds generate-db-auth-token --hostname &amp;lt;endpoint&amp;gt; --port 5432 --region &amp;lt;region&amp;gt; --username schema_reader&lt;/code&gt;, and &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.Connecting.AWSCLI.PostgreSQL.html" rel="noopener noreferrer"&gt;AWS's own psql example&lt;/a&gt; connects with &lt;code&gt;sslmode=verify-full&lt;/code&gt; against its &lt;code&gt;global-bundle.pem&lt;/code&gt; certificate bundle. One caveat from the IAM overview page: once &lt;code&gt;rds_iam&lt;/code&gt; is granted, "IAM authentication takes precedence over password authentication", so give it to a dedicated role like this one rather than to a shared login.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Credential&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;th&gt;How long it works&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Password&lt;/td&gt;
&lt;td&gt;The client's store, or a config file&lt;/td&gt;
&lt;td&gt;Until someone rotates it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS IAM token&lt;/td&gt;
&lt;td&gt;Generated on demand from your AWS session&lt;/td&gt;
&lt;td&gt;15 minutes to connect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vault or password manager output&lt;/td&gt;
&lt;td&gt;Fetched on demand&lt;/td&gt;
&lt;td&gt;Whatever the secret store allows&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How Schemity reads the schema with this role
&lt;/h2&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. It builds the diagram from &lt;code&gt;pg_catalog&lt;/code&gt;, not &lt;code&gt;information_schema&lt;/code&gt;: tables, views and materialized views from &lt;code&gt;pg_class&lt;/code&gt;, columns from &lt;code&gt;pg_attribute&lt;/code&gt;, keys and checks from &lt;code&gt;pg_constraint&lt;/code&gt;, indexes, enum labels, comments, the objects that depend on each table, and the row estimates and sizes that impact analysis uses. Every one of those queries was run as &lt;code&gt;schema_reader&lt;/code&gt; above and returned the full &lt;code&gt;app&lt;/code&gt; schema, so the role can &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;reverse engineer the database&lt;/a&gt; into a diagram and understand your schema without a single table grant.&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%2F74r413dvz5fjl26f8xjl.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%2F74r413dvz5fjl26f8xjl.webp" alt="Schemity's canvas for the app schema read as schema_reader: customers with id, discount_pct NUMERIC(5,2) default 0 and email TEXT marked U, footer field: 3, u: 1, cc: 1; orders with id, customer_id, note TEXT NULL, status ORDER_STATUS with default pending, and total NUMERIC(12,2); refunds with id and order_id; foreign key lines from orders to customers and from refunds to orders; and the big_spenders view with customer_id and spent" width="800" height="618"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To connect with it, set the schema to &lt;code&gt;app&lt;/code&gt; in the &lt;a href="https://schemity.com/doc/connect-postgresql/" rel="noopener noreferrer"&gt;PostgreSQL connection&lt;/a&gt; form; Schemity reads that schema through &lt;code&gt;search_path&lt;/code&gt;, so the &lt;code&gt;USAGE&lt;/code&gt; grant is what makes it appear. For RDS, set &lt;strong&gt;Credential&lt;/strong&gt; to &lt;strong&gt;Command&lt;/strong&gt; and paste the &lt;code&gt;generate-db-auth-token&lt;/code&gt; line: the &lt;a href="https://schemity.com/doc/connections-overview/#can-the-password-come-from-a-command-instead-of-being-stored" rel="noopener noreferrer"&gt;password command&lt;/a&gt; runs through your login shell, its output is kept in memory for five minutes, well inside the token's 15, and it is never written to disk or the keychain. Schemity asks you to approve the exact command for that connection before its first run. Set &lt;strong&gt;Encryption&lt;/strong&gt; to &lt;strong&gt;Verify full&lt;/strong&gt; with the RDS bundle as the root CA.&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%2Fmfxci2qdmq0udwf3nkr2.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%2Fmfxci2qdmq0udwf3nkr2.webp" alt="Schemity's New diagram form for a PostgreSQL connection: host shop-prod.abc123.us-east-1.rds.amazonaws.com on port 5432, username schema_reader, Credential set to Command with the command aws rds generate-db-auth-token --hostname shop-prod.abc123.us-east-1.rds.amazonaws.com --port 5432 --region us-east-1 --username schema_reader, a Test command button, database shop, schema app, and Encryption set to Verify full (recommended)" width="800" height="1344"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things this role cannot do in Schemity, by design of the grant. &lt;strong&gt;Count exactly&lt;/strong&gt; in the &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; drawer runs a real count on the table, so without &lt;code&gt;SELECT&lt;/code&gt; it reports "Count failed", with the database's reason, &lt;code&gt;permission denied for table orders&lt;/code&gt;; the finding's catalogue estimate, ~50K rows and 4.0 MB here, still shows. And &lt;strong&gt;Apply&lt;/strong&gt; on a migration fails at its first statement with a permission error, &lt;code&gt;must be owner of table&lt;/code&gt; for a change to an existing table or &lt;code&gt;permission denied for schema app&lt;/code&gt; for a new one, which is the point of a reading role: the diagram, the findings and the migration SQL are yours to review, and the change is applied by a role that owns the tables.&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%2Fo21bqdubgincwlwpcdl9.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%2Fo21bqdubgincwlwpcdl9.webp" alt="Schemity's Findings drawer for making orders.note NOT NULL on the schema_reader connection: the planned migration SET search_path TO " width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which role to give a schema tool
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Diagram, data dictionary, migration review&lt;/strong&gt;: &lt;code&gt;CONNECT&lt;/code&gt; and &lt;code&gt;USAGE&lt;/code&gt; on each schema, no table grants, and a tool that reads &lt;code&gt;pg_catalog&lt;/code&gt;. The same applies to &lt;a href="https://schemity.com/blog/database-mcp-server-sql-access-or-schema-only/" rel="noopener noreferrer"&gt;an AI agent's database MCP server&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Counting rows or sampling data&lt;/strong&gt; as well: add &lt;code&gt;SELECT&lt;/code&gt; on the specific tables, not &lt;code&gt;pg_read_all_data&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Applying migrations&lt;/strong&gt;: a separate role that owns the tables, used from your deploy pipeline or deliberately from the migration dialog.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The broader case for pointing a diagram at production is in &lt;a href="https://schemity.com/blog/documenting-production-shouldnt-feel-dangerous/" rel="noopener noreferrer"&gt;documenting a production database safely&lt;/a&gt;, and once the schema is on the canvas, &lt;a href="https://schemity.com/blog/reverse-engineer-legacy-database-group-by-domain/" rel="noopener noreferrer"&gt;grouping a legacy database by domain&lt;/a&gt; is the next step. The comments this role can read come from &lt;a href="https://schemity.com/blog/column-comments-without-a-migration/" rel="noopener noreferrer"&gt;column comments written without a migration&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>security</category>
      <category>sql</category>
    </item>
    <item>
      <title>Adding a NOT NULL Column to a Large PostgreSQL Table: Constant Default, Backfill or NOT VALID</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 01 Oct 2026 01:22:37 +0000</pubDate>
      <link>https://dev.to/tbson87/adding-a-not-null-column-to-a-large-postgresql-table-constant-default-backfill-or-not-valid-1ha3</link>
      <guid>https://dev.to/tbson87/adding-a-not-null-column-to-a-large-postgresql-table-constant-default-backfill-or-not-valid-1ha3</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 PostgreSQL 11 and later, &lt;code&gt;ADD COLUMN ... NOT NULL DEFAULT 'new'&lt;/code&gt; is a catalogue change that took 10 ms on 5,000,000 rows, but a volatile default such as &lt;code&gt;gen_random_uuid()&lt;/code&gt; rewrote the whole table in 10.4 seconds. When the value has to be computed per row, add the column as nullable, backfill it in batches, and prove it &lt;code&gt;NOT NULL&lt;/code&gt; with a constraint added &lt;code&gt;NOT VALID&lt;/code&gt; and validated separately, so the full scan never holds a lock that blocks reads. Set &lt;code&gt;lock_timeout&lt;/code&gt; on every one of these statements, because even the 10 ms version waits behind any open transaction that has touched the table and stalls every query that arrives after it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;How long it takes to add a &lt;code&gt;NOT NULL&lt;/code&gt; column to a large PostgreSQL table depends almost entirely on the default. With a constant, the statement changes the catalogue and returns in milliseconds, whatever the table's size. With a value that has to be computed for each row, it rewrites the table while nothing else can read it, and the safe version is three statements instead of one.&lt;/p&gt;

&lt;p&gt;The same &lt;code&gt;ALTER TABLE&lt;/code&gt; looks equally harmless in a migration file either way. Every statement and timing below was run on PostgreSQL 18.3 in a throwaway container, against an &lt;code&gt;orders&lt;/code&gt; table of 5,000,000 rows and 356 MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to add a NOT NULL column to a large table in Postgres
&lt;/h2&gt;

&lt;p&gt;Pick the migration by what the new column's value is for the rows that already exist:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Existing rows get&lt;/th&gt;
&lt;th&gt;Migration&lt;/th&gt;
&lt;th&gt;Time on 5M rows&lt;/th&gt;
&lt;th&gt;Lock held&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The same constant&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD COLUMN status text NOT NULL DEFAULT 'new'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9.6 ms&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;, for the catalogue change only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The time of the migration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD COLUMN imported_at timestamptz NOT NULL DEFAULT now()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.8 ms&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;, catalogue only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A new value per row&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD COLUMN public_id uuid NOT NULL DEFAULT gen_random_uuid()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10.4 s&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; for the whole rewrite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD COLUMN channel text NOT NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fails at once&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; until the error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A value you compute&lt;/td&gt;
&lt;td&gt;Nullable column, batched backfill, then prove &lt;code&gt;NOT NULL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Seconds of scanning, none of it blocking reads&lt;/td&gt;
&lt;td&gt;See below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two rows are fast because of a PostgreSQL 11 change, which the &lt;a href="https://www.postgresql.org/docs/release/11.0/" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; describe as allowing &lt;code&gt;ALTER TABLE&lt;/code&gt; "to add a column with a non-null default without doing a table rewrite". The &lt;a href="https://www.postgresql.org/docs/18/sql-altertable.html" rel="noopener noreferrer"&gt;&lt;code&gt;ALTER TABLE&lt;/code&gt; documentation&lt;/a&gt; gives the rule: a non-volatile default "is evaluated at the time of the statement and the result stored in the table's metadata", while a volatile one "will cause the entire table and its indexes to be rewritten". The table's file on disk kept its &lt;code&gt;pg_relation_filenode&lt;/code&gt; through the constant default and got a new one through &lt;code&gt;gen_random_uuid()&lt;/code&gt;, growing from 356 MB to 473 MB.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;now()&lt;/code&gt; is stable rather than volatile, so it takes the fast path too, which also means every existing order gets the same &lt;code&gt;imported_at&lt;/code&gt;: the time the migration's transaction started. That is correct for an import timestamp and wrong for anything that was meant to record when each row happened.&lt;/p&gt;

&lt;p&gt;On a table that has rows, a column with no default and &lt;code&gt;NOT NULL&lt;/code&gt; fails on the first one, with &lt;code&gt;ERROR: column "channel" of relation "orders" contains null values&lt;/code&gt;. That is the safe failure. The rewrite is the dangerous one, because it succeeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do when every row needs its own value
&lt;/h2&gt;

&lt;p&gt;Split it into steps, each of which is either instant or does its slow work without blocking reads. Say each order takes its region from its customer. Adding the column as nullable is a catalogue change (0.7 ms):&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;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Backfill in batches rather than one statement. A single &lt;code&gt;UPDATE&lt;/code&gt; joining all 5,000,000 orders to a 100,000-row &lt;code&gt;customers&lt;/code&gt; table took 19.6 seconds and kept a row lock on every order it had touched until it committed, so any other transaction updating one of those orders waited for the whole backfill. Batches of 50,000 took about 220 ms each, which keeps each lock short:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;region&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;c&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;o&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;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;50000&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="k"&gt;IS&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;-- repeat for the next range, committing between batches&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then make the column &lt;code&gt;NOT NULL&lt;/code&gt;. The obvious statement is the one to avoid on a big, busy table: &lt;code&gt;ALTER TABLE orders ALTER COLUMN region SET NOT NULL&lt;/code&gt; holds &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; while it scans every row for a &lt;code&gt;NULL&lt;/code&gt;. On a freshly vacuumed table it took 367 to 380 ms over three runs. Straight after the one-statement backfill, when the scan also had to step over 5,000,000 dead row versions and set their hint bits, it took 1.9 seconds, and for all of that time a primary key lookup on &lt;code&gt;orders&lt;/code&gt; could not run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does SET NOT NULL lock the table, and how do you avoid the scan?
&lt;/h2&gt;

&lt;p&gt;It does, and the way around it is to prove the column has no &lt;code&gt;NULL&lt;/code&gt; before &lt;code&gt;SET NOT NULL&lt;/code&gt; runs. Since PostgreSQL 12, &lt;code&gt;SET NOT NULL&lt;/code&gt; skips its scan when a validated check constraint already proves the same thing:&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;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_region_nn_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;region&lt;/span&gt; &lt;span class="k"&gt;IS&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;NOT&lt;/span&gt; &lt;span class="k"&gt;VALID&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="n"&gt;VALIDATE&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_region_nn_check&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="n"&gt;region&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="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;DROP&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_region_nn_check&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding the constraint &lt;code&gt;NOT VALID&lt;/code&gt; checks only new and updated rows, so it is instant. &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; does the full scan, 534 ms here, but under &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt;, a lock that lets reads and writes carry on. The &lt;code&gt;SET NOT NULL&lt;/code&gt; afterwards took 3.6 ms, and with &lt;code&gt;client_min_messages&lt;/code&gt; at &lt;code&gt;debug1&lt;/code&gt; PostgreSQL says why: &lt;code&gt;existing constraints on column "orders.region" are sufficient to prove that it does not contain nulls&lt;/code&gt;. The check constraint has done its job and can be dropped. Give it a name other than &lt;code&gt;orders_region_not_null&lt;/code&gt;: on PostgreSQL 18 that is the name &lt;code&gt;SET NOT NULL&lt;/code&gt; wants for its own constraint, and a clash leaves you with &lt;code&gt;orders_region_not_null1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;PostgreSQL 18 removes the detour. It stores &lt;code&gt;NOT NULL&lt;/code&gt; as a named constraint in &lt;code&gt;pg_constraint&lt;/code&gt;, and its release notes add the ability "to set the &lt;code&gt;NOT VALID&lt;/code&gt; attribute of &lt;code&gt;NOT NULL&lt;/code&gt; constraints":&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;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_region_not_null&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;region&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;VALID&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="n"&gt;VALIDATE&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_region_not_null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first statement took 0.75 ms and already rejects inserts and updates that leave region &lt;code&gt;NULL&lt;/code&gt;. The second scanned for 478 ms under &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt;. If a &lt;code&gt;NULL&lt;/code&gt; slipped through the backfill, validation fails with the same &lt;code&gt;contains null values&lt;/code&gt; error and nothing changes. On PostgreSQL 17 the first statement is a syntax error, so use the check-constraint version there.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Blocking lock held during the scan&lt;/th&gt;
&lt;th&gt;Works on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SET NOT NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;, reads and writes wait&lt;/td&gt;
&lt;td&gt;Every version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CHECK (... IS NOT NULL) NOT VALID&lt;/code&gt;, validate, &lt;code&gt;SET NOT NULL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;None, the scan runs under &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;PostgreSQL 12 and later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;NOT NULL ... NOT VALID&lt;/code&gt;, validate&lt;/td&gt;
&lt;td&gt;None, the scan runs under &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;PostgreSQL 18 and later&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why a 10 ms ALTER TABLE can still stall every query
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;ALTER TABLE&lt;/code&gt; here except &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; takes &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;, at least for an instant, and it has to wait for that lock like anything else. While it waits, it sits in the lock queue, and every query that arrives after it waits behind it, reads included. The constant-default &lt;code&gt;ADD COLUMN&lt;/code&gt; that took 9.6 ms on its own showed this on a 100,000-row copy of the table: one session held a transaction open on &lt;code&gt;orders&lt;/code&gt; for 8 seconds, the &lt;code&gt;ALTER TABLE&lt;/code&gt; took 6,986 ms because it was waiting for that transaction, and a &lt;code&gt;SELECT total FROM orders WHERE id = 1&lt;/code&gt; issued a second later took 5,995 ms. &lt;code&gt;pg_blocking_pids()&lt;/code&gt; showed the chain: the &lt;code&gt;SELECT&lt;/code&gt; blocked by the &lt;code&gt;ALTER TABLE&lt;/code&gt;, and the &lt;code&gt;ALTER TABLE&lt;/code&gt; blocked by the open transaction.&lt;/p&gt;

&lt;p&gt;An application's pool drains the same way, behind a long report query or a forgotten &lt;code&gt;BEGIN; SELECT ... FROM orders&lt;/code&gt; in someone's psql session. The fix is to let the migration give up rather than hold the queue, which the default does not do, since &lt;a href="https://www.postgresql.org/docs/18/runtime-config-client.html" rel="noopener noreferrer"&gt;&lt;code&gt;lock_timeout&lt;/code&gt;&lt;/a&gt; defaults to zero: "A value of zero (the default) disables the timeout."&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;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2s'&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;COLUMN&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'web'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the same open transaction in front of it, this failed after 2 seconds with &lt;code&gt;ERROR: canceling statement due to lock timeout&lt;/code&gt;, and the queue behind it cleared. &lt;code&gt;SET LOCAL&lt;/code&gt; ends with the transaction, so the setting does not stay behind on a pooled connection. A timeout aborts the whole transaction, so retry it from &lt;code&gt;BEGIN&lt;/code&gt; a few times from the deploy script and the migration lands in a quiet moment. No static check can see which transaction will be open when the migration runs, so this one is yours to write in every migration that touches a hot table.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity shows what the migration will cost
&lt;/h2&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. Adding a column to &lt;code&gt;orders&lt;/code&gt; on a connected diagram is how you see the impact of every change before it becomes a migration: press F7 and &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; reads the pending schema migration against the database's own catalogue.&lt;/p&gt;

&lt;p&gt;A new column drawn &lt;code&gt;NOT NULL&lt;/code&gt; with no default is reported as "Adds orders.channel NOT NULL without a default, fails on ~5M existing rows". &lt;strong&gt;Preview changes&lt;/strong&gt; shows it next to the table and the statement that would fail:&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%2F92cc66ugwf33s9u2g1y7.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%2F92cc66ugwf33s9u2g1y7.webp" alt="Schemity's change preview for adding channel TEXT to orders with no default and no NULL marker: the channel row highlighted green on the orders table, the planned migration ALTER TABLE " width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Making an existing column &lt;code&gt;NOT NULL&lt;/code&gt; gives two findings, "Sets orders.region NOT NULL, fails if any of ~5M rows is NULL" and "Checking region for NULL holds back reads and writes to orders while it reads ~5M rows, 356 MB on disk", which is the lock described above:&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%2Ffteiqy1zwhhgt9xt8d0y.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%2Ffteiqy1zwhhgt9xt8d0y.webp" alt="Schemity's change preview for making orders.region NOT NULL: the region row tinted blue as altered, the planned migration ALTER TABLE " width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the Impact drawer, &lt;strong&gt;Count exactly&lt;/strong&gt; runs a read-only count of the &lt;code&gt;NULL&lt;/code&gt; values before you decide. The row counts are catalogue estimates, so no finding scans the table. Tables under 10,000 rows are left out of the lock findings, since that lock is over before anyone waits on it. In the current release every &lt;code&gt;SET NOT NULL&lt;/code&gt; is reported as a scan, including one that a validated check constraint has already proven, so read that finding against the steps above.&lt;/p&gt;

&lt;p&gt;When the migration comes from Rails, Django, Prisma or an AI agent instead, press Shift+F7 and paste the file into the &lt;strong&gt;SQL migration&lt;/strong&gt; drawer: the same findings are reported for its statements, &lt;code&gt;BEGIN&lt;/code&gt;, &lt;code&gt;COMMIT&lt;/code&gt; and a &lt;code&gt;SET LOCAL lock_timeout&lt;/code&gt; line are skipped as transaction control and a setting rather than schema changes, and nothing in the file is executed. A file that adds &lt;code&gt;channel&lt;/code&gt; and sets it &lt;code&gt;NOT NULL&lt;/code&gt; with no backfill in between is caught before it 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%2Fbtuxccc3op5d8xgghjrj.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%2Fbtuxccc3op5d8xgghjrj.webp" alt="Schemity's change preview for a pasted migration file: the planned migration lists BEGIN, SET LOCAL lock_timeout = '2s', ALTER TABLE orders ADD COLUMN channel text, ALTER TABLE orders ALTER COLUMN channel SET NOT NULL and COMMIT; the channel row is highlighted green on the orders table, with two impact findings reading Sets orders.channel NOT NULL, fails if any of ~5M rows is NULL and Checking channel for NULL holds back reads and writes to orders while it reads ~5M rows, 356 MB on disk" width="800" height="507"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration dialog&lt;/a&gt; names the host, database, schema and environment it will run against, and on a connection tagged Production, &lt;strong&gt;Apply&lt;/strong&gt; stays disabled until you type the database name, so the 10-second version cannot land on the wrong server by a misclick. The option is per connection, described in &lt;a href="https://schemity.com/doc/connections-overview/" rel="noopener noreferrer"&gt;connections&lt;/a&gt;. The same findings are repeated above the SQL:&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%2Fz84jhkzdwl8zcw5ujehg.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%2Fz84jhkzdwl8zcw5ujehg.webp" alt="Schemity's Migration confirm dialog targeting 127.0.0.1 : pg_empty (public) marked PRODUCTION in red: under Can fail on existing data, Sets orders.region NOT NULL, fails if any of ~5M rows is NULL; under Holds back other statements, Checking region for NULL holds back reads and writes to orders while it reads ~5M rows, 356 MB on disk; the SQL statement ALTER TABLE " width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which migration to write
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The value is the same for every existing row: add the column &lt;code&gt;NOT NULL&lt;/code&gt; with a constant default, in one statement.&lt;/li&gt;
&lt;li&gt;The value differs per row: nullable column, batched backfill, then &lt;code&gt;NOT NULL ... NOT VALID&lt;/code&gt; and &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; on PostgreSQL 18, or the check-constraint version on 12 to 17.&lt;/li&gt;
&lt;li&gt;A volatile default on a big table: only when a rewrite under &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; is acceptable, which on 5,000,000 rows was 10.4 seconds of nobody reading &lt;code&gt;orders&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Every one of them: &lt;code&gt;SET LOCAL lock_timeout&lt;/code&gt; first, and retry the transaction on failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An AI agent or an ORM will write whichever version is shortest, and for a per-row value the shortest is the one that rewrites. The &lt;a href="https://schemity.com/blog/should-ai-agents-write-database-migrations/" rel="noopener noreferrer"&gt;case for reviewing agent-written migrations&lt;/a&gt; covers the other lines that look this harmless, such as a rename generated as a drop and an add, and &lt;a href="https://schemity.com/blog/schema-linting-vs-migration-linting/" rel="noopener noreferrer"&gt;schema linting vs migration linting&lt;/a&gt; explains which of these a migration linter catches from the file alone. For another default that decides whether a table is rewritten, see the stored column measurements in &lt;a href="https://schemity.com/blog/postgres-generated-column-vs-trigger/" rel="noopener noreferrer"&gt;generated column vs trigger&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PostgreSQL numeric vs double precision vs money: Which Type to Use for Prices</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Mon, 28 Sep 2026 03:21:11 +0000</pubDate>
      <link>https://dev.to/tbson87/postgresql-numeric-vs-double-precision-vs-money-which-type-to-use-for-prices-5b9j</link>
      <guid>https://dev.to/tbson87/postgresql-numeric-vs-double-precision-vs-money-which-type-to-use-for-prices-5b9j</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; Store prices and other amounts of money as &lt;code&gt;numeric&lt;/code&gt; with a declared scale, such as &lt;code&gt;numeric(12,2)&lt;/code&gt;. A &lt;code&gt;double precision&lt;/code&gt; column cannot hold 0.10 exactly, and in a 1,000,000-row test its &lt;code&gt;sum()&lt;/code&gt; gave a different answer on each parallel run. The &lt;code&gt;money&lt;/code&gt; type is exact but ties its fractional digits and output to the server's &lt;code&gt;lc_monetary&lt;/code&gt; locale, stores no currency, and truncates on division.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For prices, totals and balances in PostgreSQL, reach for &lt;code&gt;numeric(12,2)&lt;/code&gt; or another bounded &lt;code&gt;numeric&lt;/code&gt;. It is the only one of the three types that is both exact and portable across servers, and it does arithmetic the way an accountant expects. &lt;code&gt;double precision&lt;/code&gt; is fast and approximate, and &lt;code&gt;money&lt;/code&gt; is exact but carries enough surprises that the PostgreSQL wiki's list of things not to do includes it by name.&lt;/p&gt;

&lt;p&gt;The choice is usually made for you by a generator. Prisma's &lt;code&gt;Float&lt;/code&gt; maps to &lt;code&gt;double precision&lt;/code&gt; and its &lt;code&gt;Decimal&lt;/code&gt; to &lt;code&gt;decimal(65,30)&lt;/code&gt;, which allows 35 digits before the point and 30 after. Django's &lt;code&gt;DecimalField&lt;/code&gt; refuses to run without &lt;code&gt;max_digits&lt;/code&gt; and &lt;code&gt;decimal_places&lt;/code&gt;, so a Django schema at least had someone pick a number. Every statement and timing below was run on PostgreSQL 18.3 in a throwaway container.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the difference between numeric, double precision and money in PostgreSQL?
&lt;/h2&gt;

&lt;p&gt;All three accept &lt;code&gt;12.34&lt;/code&gt;. They differ in how they store it, and so in what comes back:&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;numeric(12,2)&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;double precision&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;money&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Storage in a row&lt;/td&gt;
&lt;td&gt;Variable: 7 bytes for 1234.56, 11 for 123456789.12&lt;/td&gt;
&lt;td&gt;8 bytes&lt;/td&gt;
&lt;td&gt;8 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exact for decimal amounts&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, binary approximation&lt;/td&gt;
&lt;td&gt;Yes, to the locale's fractional digits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0.1 + 0.2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.30000000000000004&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$0.30&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Too many decimals&lt;/td&gt;
&lt;td&gt;Rounded to the scale&lt;/td&gt;
&lt;td&gt;Kept, approximately&lt;/td&gt;
&lt;td&gt;Rounded to the locale's digits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Too large&lt;/td&gt;
&lt;td&gt;Error: &lt;code&gt;numeric field overflow&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Kept, with fewer exact digits&lt;/td&gt;
&lt;td&gt;Error above 92233720368547758.07&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Carries a currency&lt;/td&gt;
&lt;td&gt;No, add a column&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No, and prints the server's symbol&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;sum()&lt;/code&gt; over 1,000,000 rows&lt;/td&gt;
&lt;td&gt;26 to 37 ms&lt;/td&gt;
&lt;td&gt;20 to 22 ms&lt;/td&gt;
&lt;td&gt;21 to 22 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The timings are three runs each of &lt;code&gt;SELECT sum(...)&lt;/code&gt; on a laptop, over one table holding the same random amounts between 0 and 1,000 in all three types. &lt;code&gt;numeric&lt;/code&gt; is the slowest, by a fifth to three quarters depending on the run, which is still milliseconds on a million rows. It is rarely the reason a query is slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a float sum give a different answer each time?
&lt;/h2&gt;

&lt;p&gt;Because floating point addition is not associative, and PostgreSQL does not always add in the same order. The same table's &lt;code&gt;double precision&lt;/code&gt; column, summed seven times with the default parallel plan (two workers plus the leader), returned seven different totals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;500018482.179999
500018482.18000245
500018482.1800022
500018482.1799948
500018482.1799991
500018482.17999697
500018482.1800002
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;numeric&lt;/code&gt; column returned &lt;code&gt;500018482.18&lt;/code&gt; every time. Each worker sums its share of the rows, the leader adds the partial sums, and which rows land in which share varies from run to run. With &lt;code&gt;max_parallel_workers_per_gather = 0&lt;/code&gt; the float sum became repeatable, at &lt;code&gt;500018482.18000865&lt;/code&gt;, which is still not the right answer.&lt;/p&gt;

&lt;p&gt;The error per value is tiny, which is why it survives code review. It shows up as a reconciliation that compares two totals for equality and fails, a report that rounds a half cent the other way on a rerun, or a test that fails once a week. The PostgreSQL &lt;a href="https://www.postgresql.org/docs/current/datatype-numeric.html" rel="noopener noreferrer"&gt;numeric types documentation&lt;/a&gt; says it plainly: "If you require exact storage and calculations (such as for monetary amounts), use the &lt;code&gt;numeric&lt;/code&gt; type instead."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;double precision&lt;/code&gt; is the right type for measurements, where the input was approximate to begin with: a temperature, a latitude, a sensor reading, a score. It is the wrong type for anything that someone will add up and compare against an invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postgres money type vs numeric: which should I use?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;numeric&lt;/code&gt;. The &lt;code&gt;money&lt;/code&gt; type looks made for the job, and it is exact: &lt;code&gt;sum()&lt;/code&gt; over the same million rows returned &lt;code&gt;$500,018,482.18&lt;/code&gt; every run, as fast as the float. But three of its properties are hard to live with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Its digits and its output come from the server.&lt;/strong&gt; The &lt;a href="https://www.postgresql.org/docs/current/datatype-money.html" rel="noopener noreferrer"&gt;money type documentation&lt;/a&gt; says the fractional precision "is determined by the database's &lt;code&gt;lc_monetary&lt;/code&gt; setting", and warns that since the output is locale-sensitive, "it might not work to load money data into a database that has a different setting of &lt;code&gt;lc_monetary&lt;/code&gt;". A dump taken on one server can fail to restore, or read differently, on another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It stores no currency.&lt;/strong&gt; &lt;code&gt;SELECT 12.34::money&lt;/code&gt; prints &lt;code&gt;$12.34&lt;/code&gt; on a server whose &lt;code&gt;lc_monetary&lt;/code&gt; is &lt;code&gt;en_US&lt;/code&gt;, whatever currency the row was in. A multi-currency table needs a currency column either way, and at that point &lt;code&gt;money&lt;/code&gt; adds nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integer division truncates.&lt;/strong&gt; The same documentation says that dividing money by an integer truncates "the fractional part towards zero". &lt;code&gt;SELECT 10::money / 3&lt;/code&gt; returns &lt;code&gt;$3.33&lt;/code&gt;, and multiplying it back gives &lt;code&gt;$9.99&lt;/code&gt;. &lt;code&gt;numeric&lt;/code&gt; returns &lt;code&gt;3.3333333333333333&lt;/code&gt; and leaves the rounding to you, which is where it belongs, because splitting a bill is a business rule.&lt;/p&gt;

&lt;p&gt;The PostgreSQL wiki's &lt;a href="https://wiki.postgresql.org/wiki/Don%27t_Do_This" rel="noopener noreferrer"&gt;Don't Do This&lt;/a&gt; page gives the narrow case where &lt;code&gt;money&lt;/code&gt; is fine: a single currency, no fractions of a cent, and only addition and subtraction. It also cannot be cast to &lt;code&gt;double precision&lt;/code&gt; at all: &lt;code&gt;12.34::money::float8&lt;/code&gt; fails with &lt;code&gt;cannot cast type money to double precision&lt;/code&gt;, so a conversion has to go through &lt;code&gt;numeric&lt;/code&gt;. &lt;code&gt;numeric&lt;/code&gt; has none of these limits, so there is little reason to accept them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What precision and scale should a numeric price column have?
&lt;/h2&gt;

&lt;p&gt;Pick the scale first: it is the smallest unit you ever store. Two decimal places fit most currencies, three fit the Kuwaiti dinar and the Bahraini dinar, and a unit price, a tax rate or an exchange rate often needs four to eight. Then pick the precision so that precision minus scale covers the largest amount the column will ever hold.&lt;/p&gt;

&lt;p&gt;The two limits fail differently, and the manual spells out both: a value with more decimals than the scale "will round the value to the specified number of fractional digits", while a value with too many digits before the point raises an error. So &lt;code&gt;numeric(12,2)&lt;/code&gt; quietly stores &lt;code&gt;1.005&lt;/code&gt; as &lt;code&gt;1.01&lt;/code&gt; and refuses &lt;code&gt;12345678901.00&lt;/code&gt; with &lt;code&gt;numeric field overflow&lt;/code&gt;. The rounding is the one to think about, because nothing tells you it happened.&lt;/p&gt;

&lt;p&gt;An unconstrained &lt;code&gt;numeric&lt;/code&gt; is a legitimate choice too. It keeps every digit you give it, which suits an intermediate calculation or an exchange rate, but it also accepts &lt;code&gt;0.333333...&lt;/code&gt; in a column the application believes holds cents. For a column people read as money, declare the scale so the database enforces it, and do it early: adding the bound later rewrites the table and rounds every stored value.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does migrating a price column to numeric cost?
&lt;/h2&gt;

&lt;p&gt;Changing a price column's type is where the choice turns into a schema migration, and PostgreSQL treats the variations very differently. Timed on a 1,000,000-row, 42 MB table with a &lt;code&gt;bigint&lt;/code&gt; primary key, three runs each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Rewrites the table&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Can fail or change values&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;double precision&lt;/code&gt; to &lt;code&gt;numeric(12,2)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;1.2 to 1.9 s&lt;/td&gt;
&lt;td&gt;Rounds every value to 2 places; rejects anything that rounds to 10,000,000,000 or more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;numeric(12,2)&lt;/code&gt; to &lt;code&gt;numeric(14,2)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;about 1 ms&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;numeric(12,2)&lt;/code&gt; to unconstrained &lt;code&gt;numeric&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;about 1 ms&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unconstrained &lt;code&gt;numeric&lt;/code&gt; back to &lt;code&gt;numeric(12,2)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;not timed&lt;/td&gt;
&lt;td&gt;Rounds every value to 2 places; rejects anything too large&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;numeric(14,2)&lt;/code&gt; to &lt;code&gt;numeric(14,4)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;0.6 to 1.3 s&lt;/td&gt;
&lt;td&gt;Fails if any value has more than 10 integer digits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cheap rows come from PostgreSQL 9.2, whose &lt;a href="https://www.postgresql.org/docs/release/9.2.0/" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; say that "increasing the allowable precision of a numeric column, or changing a column from constrained numeric to unconstrained numeric, no longer requires a table rewrite". Raising the scale does not share that exemption and always rewrites. At the same precision it can also fail: &lt;code&gt;numeric(12,2)&lt;/code&gt; to &lt;code&gt;numeric(12,4)&lt;/code&gt; leaves room for only 8 integer digits, so a stored &lt;code&gt;1234567890.12&lt;/code&gt; fails the whole statement with &lt;code&gt;numeric field overflow&lt;/code&gt;, and the detail line says &lt;code&gt;A field with precision 12, scale 4 must round to an absolute value less than 10^8&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A rewrite holds an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock for its whole duration, so reads and writes on the table wait. Two seconds on a laptop becomes minutes on a table a hundred times the size, which is why the float-to-numeric fix is worth doing while the table is small.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity finds float price columns and shows what the fix costs
&lt;/h2&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;/p&gt;

&lt;p&gt;When you &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;connect a PostgreSQL database&lt;/a&gt;, each entity draws a numeric column with its precision and scale, so &lt;code&gt;list_price&lt;/code&gt; reads &lt;code&gt;NUMERIC(12,2)&lt;/code&gt; rather than a bare &lt;code&gt;NUMERIC&lt;/code&gt;, and the number that decides what the column can hold is on the canvas where you design the next one. A &lt;code&gt;double precision&lt;/code&gt; column is drawn as &lt;code&gt;FLOAT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F01ynuadgtwukylbdty6n.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%2F01ynuadgtwukylbdty6n.webp" alt="Schemity canvas showing a products table read from a live PostgreSQL 18.3 database: id BIGINT as the primary key, list_price NUMERIC(12,2), name TEXT, unit_price FLOAT, and weight_kg FLOAT with NULL in its default cell and an N marker for nullable" width="800" height="507"&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; carries a rule called &lt;code&gt;money-as-float&lt;/code&gt;. It flags a column stored as &lt;code&gt;FLOAT&lt;/code&gt;, &lt;code&gt;DOUBLE&lt;/code&gt; or &lt;code&gt;REAL&lt;/code&gt; whose name ends in a money or quantity word (&lt;code&gt;price&lt;/code&gt;, &lt;code&gt;amount&lt;/code&gt;, &lt;code&gt;total&lt;/code&gt;, &lt;code&gt;cost&lt;/code&gt;, &lt;code&gt;balance&lt;/code&gt;, &lt;code&gt;fee&lt;/code&gt;, &lt;code&gt;salary&lt;/code&gt;, &lt;code&gt;qty&lt;/code&gt;, &lt;code&gt;quantity&lt;/code&gt;, or anything ending in &lt;code&gt;total&lt;/code&gt;, like &lt;code&gt;subtotal&lt;/code&gt;), and marks it on the exact field row with the explanation that totals drift as rows accumulate. It matches the last word only, so &lt;code&gt;coffee_temperature&lt;/code&gt; and &lt;code&gt;costume_size&lt;/code&gt; stay quiet, and so does a measurement like &lt;code&gt;weight_kg&lt;/code&gt;, which is a fair use of a float. It sits in the same Costs group as the rule behind &lt;a href="https://schemity.com/blog/postgres-timestamp-vs-timestamptz/" rel="noopener noreferrer"&gt;the timestamp an ORM chose for you&lt;/a&gt;: the schema works, it just keeps charging you.&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%2F0xnd8ia6pqq27lsmo6kp.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%2F0xnd8ia6pqq27lsmo6kp.webp" alt="Schemity's lint drawer open beside the products table with one finding in the Costs group: products.unit_price, Money or quantity stored in binary floating point, FLOAT cannot represent every decimal value exactly, so totals drift as rows accumulate, with Ignore, Copy and Show on canvas actions and a marker beside the unit_price row on the canvas" width="800" height="507"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you change the column's type in the diagram, &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; reads the pending migration before you apply it. For &lt;code&gt;unit_price&lt;/code&gt; going from &lt;code&gt;FLOAT&lt;/code&gt; to &lt;code&gt;NUMERIC(12,2)&lt;/code&gt; on the million-row table it reports three things: values may not survive the cast, the change rewrites &lt;code&gt;products&lt;/code&gt;, and it holds back reads and writes while it does, each with the row count and the 87 MB the table takes on disk. It tells the cheap changes apart from the expensive ones too: raising &lt;code&gt;NUMERIC(12,2)&lt;/code&gt; to &lt;code&gt;NUMERIC(14,2)&lt;/code&gt; reports no rewrite, while &lt;code&gt;NUMERIC(12,2)&lt;/code&gt; to &lt;code&gt;NUMERIC(12,4)&lt;/code&gt; is flagged as a change that can lose values, because it takes away two integer digits.&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%2Fw9nycl6bjbhode5voov1.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%2Fw9nycl6bjbhode5voov1.webp" alt="Schemity's findings drawer for unit_price changed from FLOAT to NUMERIC(12,2): the planned migration ALTER TABLE " width="800" height="546"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same analysis runs on a migration file your ORM or an AI agent wrote. An agent that reads the schema over MCP gets each column's full type from &lt;code&gt;get_schema&lt;/code&gt;, so a field it copies comes back as &lt;code&gt;NUMERIC(12,2)&lt;/code&gt; and not as a numeric with the scale lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing a type for money in PostgreSQL
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;numeric(p,s)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prices, totals, balances, invoices, anything someone adds up and compares against a ledger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unconstrained &lt;code&gt;numeric&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Exchange rates and intermediate results where you want every digit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;double precision&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Measurements that were approximate to begin with; never money&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;money&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A single-currency system with no fractions of a cent that only adds and subtracts, and never moves between servers with different locales&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;bigint&lt;/code&gt; in minor units&lt;/td&gt;
&lt;td&gt;When every amount has a fixed number of decimals and the application does all rounding; store the currency and its exponent beside it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same kind of decision shows up across the schema: whether &lt;a href="https://schemity.com/blog/postgres-varchar-vs-text/" rel="noopener noreferrer"&gt;a &lt;code&gt;varchar&lt;/code&gt; limit is a rule or a habit&lt;/a&gt;, whether a status belongs in &lt;a href="https://schemity.com/blog/postgres-enum-vs-check-constraint-vs-lookup-table/" rel="noopener noreferrer"&gt;an enum, a check constraint or a lookup table&lt;/a&gt;, and whether a derived total should be &lt;a href="https://schemity.com/blog/postgres-generated-column-vs-trigger/" rel="noopener noreferrer"&gt;a generated column or a trigger&lt;/a&gt;. In each case the type is picked once, usually by a generator, and then copied into every table that follows.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PostgreSQL Generated Column vs Trigger: Which to Use for a Derived Column</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Sun, 27 Sep 2026 07:10:10 +0000</pubDate>
      <link>https://dev.to/tbson87/postgresql-generated-column-vs-trigger-which-to-use-for-a-derived-column-518p</link>
      <guid>https://dev.to/tbson87/postgresql-generated-column-vs-trigger-which-to-use-for-a-derived-column-518p</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Use a generated column when the value comes from other columns in the same row through immutable functions, because the database guarantees it and nobody can write to it. Use a trigger only when the value needs another table, the current time, or a function PostgreSQL does not call immutable. On PostgreSQL 18 a generated column is virtual unless you write &lt;code&gt;STORED&lt;/code&gt;, and a virtual one cannot be indexed directly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A PostgreSQL generated column is the better choice for any derived value it can express, because the database computes it on every write and refuses to let anyone set it by hand. A trigger is the fallback for the values a generated column is not allowed to compute: anything that reads another table, the clock, or a function PostgreSQL does not mark immutable.&lt;/p&gt;

&lt;p&gt;The choice matters more than it looks, and it matters twice. Once when you write the table, because the two differ in cost, in what they allow, and in how they fail. And again when someone else inherits it: a generated column says what it is in the table definition, while a trigger-maintained column looks exactly like a column the application writes. Every PostgreSQL statement below was run on PostgreSQL 18.3 in a throwaway container.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the difference between a generated column and a trigger in PostgreSQL?
&lt;/h2&gt;

&lt;p&gt;Take an &lt;code&gt;order_lines&lt;/code&gt; table where &lt;code&gt;line_total&lt;/code&gt; is &lt;code&gt;unit_price * quantity&lt;/code&gt;. As a generated column it is one line:&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;order_lines&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="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&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;unit_price&lt;/span&gt; &lt;span class="nb"&gt;numeric&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;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;quantity&lt;/span&gt;   &lt;span class="nb"&gt;integer&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;line_total&lt;/span&gt; &lt;span class="nb"&gt;numeric&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;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&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;unit_price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As a trigger it is a plain column plus a function and a trigger that keeps it up to date (drop the first table before running this one, since both are called &lt;code&gt;order_lines&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;order_lines&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="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&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;unit_price&lt;/span&gt; &lt;span class="nb"&gt;numeric&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;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;quantity&lt;/span&gt;   &lt;span class="nb"&gt;integer&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;line_total&lt;/span&gt; &lt;span class="nb"&gt;numeric&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="p"&gt;);&lt;/span&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_line_total&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;line_total&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&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;unit_price&lt;/span&gt; &lt;span class="o"&gt;*&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;quantity&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="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;order_lines_total&lt;/span&gt; &lt;span class="k"&gt;BEFORE&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;unit_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;order_lines&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_line_total&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both produce 29.97 for a price of 9.99 and a quantity of 3. The differences are everywhere else:&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;Generated column&lt;/th&gt;
&lt;th&gt;Trigger-maintained column&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it can read&lt;/td&gt;
&lt;td&gt;Columns of the same row, through immutable functions&lt;/td&gt;
&lt;td&gt;Anything: other tables, &lt;code&gt;now()&lt;/code&gt;, any function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can be written by hand&lt;/td&gt;
&lt;td&gt;No, &lt;code&gt;INSERT&lt;/code&gt; and &lt;code&gt;UPDATE&lt;/code&gt; are rejected&lt;/td&gt;
&lt;td&gt;Yes, and the value stays wrong until the next trigger run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visible in the table definition&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;GENERATED ALWAYS AS (...)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No, it is a plain column&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert 1,000,000 rows (plain table: 1.58 s)&lt;/td&gt;
&lt;td&gt;1.83 s stored, 1.52 s virtual&lt;/td&gt;
&lt;td&gt;2.72 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skipped by &lt;code&gt;session_replication_role = replica&lt;/code&gt;
&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;Drop a column it reads&lt;/td&gt;
&lt;td&gt;Refused&lt;/td&gt;
&lt;td&gt;Refused only for columns in &lt;code&gt;UPDATE OF&lt;/code&gt;; otherwise allowed, and the next write fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can be indexed&lt;/td&gt;
&lt;td&gt;Stored yes; virtual only through an index on its expression&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 insert timings are the average of three runs each of one &lt;code&gt;INSERT ... SELECT&lt;/code&gt; over &lt;code&gt;generate_series(1, 1000000)&lt;/code&gt; on a laptop, so read them as ratios: a row-level PL/pgSQL trigger added about 72% to the plain insert, the stored generated column about 16%, and the virtual one nothing measurable, because it writes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I use a generated column or a trigger for a derived column?
&lt;/h2&gt;

&lt;p&gt;Use a generated column when every input is in the same row and every function is immutable. That covers arithmetic like &lt;code&gt;line_total&lt;/code&gt;, normalised copies like &lt;code&gt;lower(email)&lt;/code&gt;, and extracted values like a &lt;code&gt;tsvector&lt;/code&gt; built with &lt;code&gt;to_tsvector('english', body)&lt;/code&gt; or a field pulled out of a &lt;code&gt;jsonb&lt;/code&gt; document. The one-argument &lt;code&gt;to_tsvector(body)&lt;/code&gt; is rejected, because it depends on a server setting. PostgreSQL enforces the rules itself when you create the column:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GENERATED ALWAYS AS (now()) STORED&lt;/code&gt; fails with &lt;code&gt;generation expression is not immutable&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A subquery fails with &lt;code&gt;cannot use subquery in column generation expression&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A generated column that reads another generated column fails with &lt;code&gt;cannot use generated column "b" in column generation expression&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The third one has an easy way round: repeat the other column's expression inline. The first two are the cases where a trigger is the right answer. The common ones are an &lt;code&gt;updated_at&lt;/code&gt; set to &lt;code&gt;now()&lt;/code&gt; on every update, an &lt;code&gt;orders.total&lt;/code&gt; that sums &lt;code&gt;order_lines&lt;/code&gt;, and a denormalised copy of a parent's value, such as a customer's name stamped onto an invoice. None of them can be a generated column, and each of them is a trigger or a query.&lt;/p&gt;

&lt;p&gt;If a generated column can express the value, the trigger buys nothing but risk. The table above shows three kinds of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A trigger can be bypassed.&lt;/strong&gt; With the trigger declared &lt;code&gt;BEFORE INSERT OR UPDATE OF unit_price, quantity&lt;/code&gt;, running &lt;code&gt;UPDATE order_lines SET line_total = 5&lt;/code&gt; succeeds and leaves &lt;code&gt;line_total&lt;/code&gt; at 5.00 against a price of 9.99 and a quantity of 3, because the statement touched neither column the trigger listens to. Leave out &lt;code&gt;UPDATE OF&lt;/code&gt; and the trigger fires on every update, which fixes this and costs a function call on updates that never touch the price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A trigger can be switched off.&lt;/strong&gt; &lt;code&gt;SET session_replication_role = replica&lt;/code&gt;, which the PostgreSQL documentation says logical replication systems set while applying changes, stops ordinary triggers from firing. An insert under it left &lt;code&gt;line_total&lt;/code&gt; &lt;code&gt;NULL&lt;/code&gt;. Setting it needs superuser or a granted &lt;code&gt;SET&lt;/code&gt; privilege, and &lt;code&gt;ALTER TABLE ... DISABLE TRIGGER&lt;/code&gt; switches a trigger off the same way. A generated column is computed regardless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A trigger fails late.&lt;/strong&gt; PostgreSQL records that the trigger depends on the columns named in &lt;code&gt;UPDATE OF&lt;/code&gt;, so dropping &lt;code&gt;unit_price&lt;/code&gt; is refused. But it does not read the function body. Renaming &lt;code&gt;unit_price&lt;/code&gt; to &lt;code&gt;price&lt;/code&gt; succeeds, and the next insert fails with &lt;code&gt;record "new" has no field "unit_price"&lt;/code&gt;. The migration passed; the application broke on its next write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stored or virtual: which generated column does PostgreSQL 18 give you?
&lt;/h2&gt;

&lt;p&gt;PostgreSQL 12 introduced generated columns as stored only. PostgreSQL 18 added virtual ones and made them the default, so &lt;code&gt;GENERATED ALWAYS AS (unit_price * quantity)&lt;/code&gt; with no keyword now computes the value when a row is read and stores nothing. The &lt;a href="https://www.postgresql.org/docs/18/ddl-generated-columns.html" rel="noopener noreferrer"&gt;generated columns documentation&lt;/a&gt; lists what a virtual column gives up: it cannot use user-defined functions or types, and logical replication can publish only stored ones. Creating an index on one fails with &lt;code&gt;indexes on virtual generated columns are not supported&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The difference shows most when you add the column to a table that already has data. On the same 1,000,000-row table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Adding &lt;code&gt;line_total&lt;/code&gt; to an existing table&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD COLUMN ... GENERATED ALWAYS AS (...) VIRTUAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;about 1 ms&lt;/td&gt;
&lt;td&gt;Catalogue change only, no rewrite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD COLUMN ... GENERATED ALWAYS AS (...) STORED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;630 ms&lt;/td&gt;
&lt;td&gt;Rewrites the table under &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;; reads and writes wait&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain &lt;code&gt;ADD COLUMN&lt;/code&gt;, then &lt;code&gt;UPDATE order_lines SET line_total = unit_price * quantity&lt;/code&gt; to backfill before the trigger takes over&lt;/td&gt;
&lt;td&gt;under 1 ms, then 2.9 s&lt;/td&gt;
&lt;td&gt;Writes a new version of every row; reads continue, writes to those rows wait&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each timing is the same in three runs to within a few percent. The stored column took the table from 50 MB to 58 MB on disk and the virtual one left it at 50 MB, while the backfill doubled it to 107 MB until vacuum reclaims the old row versions. Changing the expression later is &lt;code&gt;ALTER TABLE ... ALTER COLUMN line_total SET EXPRESSION AS (...)&lt;/code&gt;, available since PostgreSQL 17. For a stored column it rewrites the table again under the same lock; for a virtual one it is a catalogue change.&lt;/p&gt;

&lt;p&gt;A virtual column cannot be indexed itself, but that matters less than it sounds: PostgreSQL expands it when it plans a query, so in the same container an index on the expression itself served a &lt;code&gt;WHERE&lt;/code&gt; on the virtual column. So the rule inside the rule: virtual when the value is cheap to compute, stored when computing it on every read costs more than storing it, or when a logical replication subscriber needs the value.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I find the computed columns in a database I did not write?
&lt;/h2&gt;

&lt;p&gt;A generated column is in the catalogue, so one query finds all of them:&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attgenerated&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="s1"&gt;'s'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'stored'&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'virtual'&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_get_expr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adbin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adrelid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;expression&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_attribute&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_attrdef&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attrelid&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adnum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attnum&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attgenerated&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attisdropped&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A trigger-maintained column is not. The catalogue knows which triggers exist and which columns their &lt;code&gt;UPDATE OF&lt;/code&gt; names, but not which column a function writes, so the only way to find &lt;code&gt;line_total&lt;/code&gt; is to list the triggers and read each function body:&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;tg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tgrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tgname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prosrc&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_trigger&lt;/span&gt; &lt;span class="n"&gt;tg&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_proc&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tgfoid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tgisinternal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That asymmetry is the strongest argument for the generated column. To understand your schema, you need to know which values the application writes and which the database derives, and only one of the two approaches tells you without reading code.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity shows generated columns and the triggers behind a column
&lt;/h2&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;/p&gt;

&lt;p&gt;When you &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;connect a PostgreSQL database&lt;/a&gt;, Schemity reads its generated columns, as it does on MySQL, SQLite, and SQL Server, where they are called computed columns. On the canvas, a generated column's default cell shows &lt;code&gt;=&lt;/code&gt; and its expression next to its type, so &lt;code&gt;line_total&lt;/code&gt; reads &lt;code&gt;= unit_price * (quantity)::numeric&lt;/code&gt;, the expression as PostgreSQL stores it. When Schemity sizes an entity to fit its content, it makes room for the first 24 characters of an expression, so one long expression does not stretch the table; widen the entity and the rest appears. SVG export draws it the same way.&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%2Fe42r2iu6b2obcmueq4iv.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%2Fe42r2iu6b2obcmueq4iv.webp" alt="Schemity canvas showing the order_lines table read from a live PostgreSQL 18.3 database: id BIGINT as the primary key, line_total NUMERIC(12,2) with = unit_price * (quantity)::numeric in its default cell and an N marker for nullable, quantity INTEGER, and unit_price NUMERIC(12,2)" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open the column and the field dialog shows the full expression and says whether the database stores the value or computes it when read. The name and the description stay editable. Everything else is greyed out, including the type, the default, precision and scale, and the key and nullable checkboxes, because the expression and what follows from it belong to the 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%2F4ei9fncdz17ybtofe3or.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%2F4ei9fncdz17ybtofe3or.webp" alt="Schemity's field dialog for line_total: an info banner reading The database computes this column and stores it, with the expression unit_price * (quantity)::numeric, an editable field name and description, and a greyed-out NUMERIC type, default, precision 12, scale 2, and PK, Unique and Nullable checkboxes, with Nullable ticked" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The diagram does not draw triggers, so a trigger-maintained &lt;code&gt;line_total&lt;/code&gt; looks like a plain column there, the same as in &lt;code&gt;psql&lt;/code&gt;: a nullable &lt;code&gt;NUMERIC(12,2)&lt;/code&gt; with &lt;code&gt;NULL&lt;/code&gt; in its default cell, like any nullable column with no default.&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%2Fpozrgjuk4rvek57hektj.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%2Fpozrgjuk4rvek57hektj.webp" alt="Schemity canvas showing the trigger version of order_lines read from PostgreSQL 18.3: id BIGINT, line_total NUMERIC(12,2) with NULL in its default cell and an N marker, quantity INTEGER, and unit_price NUMERIC(12,2), with nothing marking line_total as maintained by a trigger" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Triggers appear where they cost you something, in &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt;. Rename &lt;code&gt;quantity&lt;/code&gt; in a diagram of either version of the table and the report lists every object that depends on it and what PostgreSQL will do to each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For the generated column: "generated column line_total depends on order_lines.quantity. The database updates it to match."&lt;/li&gt;
&lt;li&gt;For the trigger: "trigger order_lines_total depends on order_lines.quantity. It fails the next time it runs if it uses what changed."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the generated column's version in &lt;strong&gt;Preview changes&lt;/strong&gt;. The canvas still shows the expression as the database holds it today, &lt;code&gt;(quantity)&lt;/code&gt;, because PostgreSQL only rewrites it to &lt;code&gt;qty&lt;/code&gt; when the rename runs; a re-sync afterwards shows the new text.&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%2Ffjpgc9b7ndwcestqcd33.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%2Ffjpgc9b7ndwcestqcd33.webp" alt="Schemity's Preview changes for renaming quantity to qty on order_lines: the planned migration ALTER TABLE " width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same rename on the trigger version gets a different verdict. PostgreSQL follows the rename in the trigger's &lt;code&gt;UPDATE OF&lt;/code&gt; list, but not inside the function body, so the finding says the trigger may fail on its next run rather than that anything is updated to match.&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%2Fs8wt8de2ecu0fs2c9kl5.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%2Fs8wt8de2ecu0fs2c9kl5.webp" alt="Schemity's Preview changes for the same rename on the trigger version of order_lines: one planned change reading column order_lines.quantity renamed to qty, no lint findings, and one impact finding - 1 object depends on order_lines.quantity, trigger order_lines_total depends on order_lines.quantity, it fails the next time it runs if it uses what changed - beside the order_lines entity with the qty row marked as altered and line_total showing NULL" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Dropping a column that a generated column reads is reported as refused, since PostgreSQL, MySQL, SQL Server and SQLite all refuse it. The drop still shows as losing the column's data, and the second finding says the statement will not get that far:&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%2F81td6l6qdat93792nqsu.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%2F81td6l6qdat93792nqsu.webp" alt="Schemity's Preview changes for dropping quantity from order_lines: the planned migration ALTER TABLE " width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Apply it anyway and PostgreSQL returns &lt;code&gt;cannot drop column quantity of table order_lines because other objects depend on it&lt;/code&gt;, with the detail line &lt;code&gt;column line_total of table order_lines depends on column quantity of table order_lines&lt;/code&gt;, and the transaction rolls back with both columns still in place.&lt;/p&gt;

&lt;p&gt;Changing its type is refused on PostgreSQL and SQL Server, and goes through on MySQL, and on SQLite through the table rebuild a type change needs there; the report says which. The same report runs on a migration file written by Django, Rails, Prisma or an AI agent before it is applied. If you inherited the schema, write down what each trigger maintains as a column description in the diagram, where the next reader will see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;updated_at&lt;/code&gt; column is the most common trigger in any schema, and whether it should be &lt;code&gt;timestamp&lt;/code&gt; or &lt;code&gt;timestamptz&lt;/code&gt; is in &lt;a href="https://schemity.com/blog/postgres-timestamp-vs-timestamptz/" rel="noopener noreferrer"&gt;timestamp vs timestamptz&lt;/a&gt;. The lock that a stored generated column takes on an existing table is the same one covered 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;. And for mapping a legacy database you did not write, start with &lt;a href="https://schemity.com/blog/reverse-engineer-legacy-database-group-by-domain/" rel="noopener noreferrer"&gt;grouping its tables by domain&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>performance</category>
    </item>
    <item>
      <title>PostgreSQL Unique Constraint vs Unique Index: Which to Use, and How to Add One Without Locking the Table</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Sat, 26 Sep 2026 13:31:21 +0000</pubDate>
      <link>https://dev.to/tbson87/postgresql-unique-constraint-vs-unique-index-which-to-use-and-how-to-add-one-without-locking-the-j6f</link>
      <guid>https://dev.to/tbson87/postgresql-unique-constraint-vs-unique-index-which-to-use-and-how-to-add-one-without-locking-the-j6f</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Use a unique constraint when the rule covers every row and plain columns, because only a constraint can be &lt;code&gt;DEFERRABLE&lt;/code&gt; or named in &lt;code&gt;ON CONFLICT ON CONSTRAINT&lt;/code&gt;, though not both at once. Use a unique index when the rule needs a &lt;code&gt;WHERE&lt;/code&gt; clause or an expression such as &lt;code&gt;lower(email)&lt;/code&gt;. On a large table, add either one by building the index with &lt;code&gt;CREATE UNIQUE INDEX CONCURRENTLY&lt;/code&gt; first, because a plain &lt;code&gt;ALTER TABLE ... ADD CONSTRAINT ... UNIQUE&lt;/code&gt; holds back reads and writes until it has checked every row.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In PostgreSQL a unique constraint and a unique index enforce uniqueness the same way. The constraint is implemented by a unique index that PostgreSQL creates for you. Where they differ is what you can attach to them, and how much of the table each one locks while it is being added. On a table with a million rows, the lock is the part you will notice.&lt;/p&gt;

&lt;p&gt;The choice is usually made by the framework before anyone thinks about it. On an existing table, Django's &lt;code&gt;unique=True&lt;/code&gt; and a plain &lt;code&gt;UniqueConstraint&lt;/code&gt; emit &lt;code&gt;ALTER TABLE ... ADD CONSTRAINT ... UNIQUE&lt;/code&gt;, and switch to &lt;code&gt;CREATE UNIQUE INDEX&lt;/code&gt; only when the constraint has a condition or an expression. Rails' &lt;code&gt;add_index :users, :email, unique: true&lt;/code&gt; always emits &lt;code&gt;CREATE UNIQUE INDEX&lt;/code&gt;. Both look like one harmless line in a migration file, and on a large table the two statements lock it in different ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the difference between a unique constraint and a unique index?
&lt;/h2&gt;

&lt;p&gt;Everything below was run on PostgreSQL 18.3:&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;Unique constraint&lt;/th&gt;
&lt;th&gt;Unique index&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How it is enforced&lt;/td&gt;
&lt;td&gt;A unique B-tree index created for it&lt;/td&gt;
&lt;td&gt;Itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can be the target of a foreign key&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, unless it is partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ON CONFLICT (email)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes, unless it is &lt;code&gt;DEFERRABLE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes (a partial index needs the matching &lt;code&gt;WHERE&lt;/code&gt; too)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ON CONFLICT ON CONSTRAINT name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes, unless it is &lt;code&gt;DEFERRABLE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No, the constraint does not exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial, e.g. &lt;code&gt;WHERE deleted_at IS NULL&lt;/code&gt;
&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;On an expression, e.g. &lt;code&gt;lower(email)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No, syntax error&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEFERRABLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, syntax error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;NULLS NOT DISTINCT&lt;/code&gt; (PostgreSQL 15+)&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;Can be built &lt;code&gt;CONCURRENTLY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not directly&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock while being added&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;: reads and writes wait&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SHARE&lt;/code&gt;: writes wait, reads continue&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows are worth a sentence. &lt;code&gt;DEFERRABLE&lt;/code&gt; is the reason some tables need a constraint: with &lt;code&gt;UNIQUE (pos) DEFERRABLE&lt;/code&gt;, &lt;code&gt;UPDATE t SET pos = pos + 1&lt;/code&gt; succeeds, because uniqueness is checked at the end of the statement rather than row by row (&lt;code&gt;INITIALLY DEFERRED&lt;/code&gt; moves the check to commit). The same update against a unique index, or against a constraint that is not deferrable, fails partway with &lt;code&gt;duplicate key value violates unique constraint&lt;/code&gt;, even though the final state is valid. The price is &lt;code&gt;ON CONFLICT&lt;/code&gt;: PostgreSQL refuses a deferrable constraint as its arbiter, in either form. The partial and expression rows are the reason some rules can only be an index, and they are the rules most tables actually have: an email unique among live accounts, or unique regardless of case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I use a unique constraint or a unique index in PostgreSQL?
&lt;/h2&gt;

&lt;p&gt;Default to the constraint and switch to the index when the rule needs something only an index can express:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The rule covers every row, on plain columns:&lt;/strong&gt; use a unique constraint. It is recorded in &lt;code&gt;pg_constraint&lt;/code&gt; as a constraint, which is where schema tools read uniqueness rules from, and &lt;code&gt;\d users&lt;/code&gt; labels it &lt;code&gt;UNIQUE CONSTRAINT&lt;/code&gt; rather than listing a bare index. It also keeps &lt;code&gt;DEFERRABLE&lt;/code&gt; or &lt;code&gt;ON CONFLICT ON CONSTRAINT&lt;/code&gt; available, one or the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rule has a condition&lt;/strong&gt; (unique among rows that are not soft-deleted): use a partial unique index. No constraint can say it. The trade-off is that no foreign key can point at it, and every &lt;code&gt;ON CONFLICT&lt;/code&gt; has to repeat its &lt;code&gt;WHERE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rule is on an expression&lt;/strong&gt; (&lt;code&gt;lower(email)&lt;/code&gt;): use a unique index on the expression, or &lt;a href="https://schemity.com/blog/postgres-generated-column-vs-trigger/" rel="noopener noreferrer"&gt;store the normalised value in a generated column&lt;/a&gt; and put a constraint on that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need to reorder or swap values in one statement:&lt;/strong&gt; use a &lt;code&gt;DEFERRABLE&lt;/code&gt; constraint, and give up &lt;code&gt;ON CONFLICT&lt;/code&gt; on that key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one thing that should not decide it is the lock, because the lock problem has the same answer for both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Will adding a unique constraint lock the table?
&lt;/h2&gt;

&lt;p&gt;Yes, and harder than the unique index. In a throwaway database, with &lt;code&gt;ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email)&lt;/code&gt; held open in one session, a plain &lt;code&gt;SELECT count(*) FROM users&lt;/code&gt; in a second session timed out on its lock. With &lt;code&gt;CREATE UNIQUE INDEX&lt;/code&gt; held open instead, the same &lt;code&gt;SELECT&lt;/code&gt; returned at once and only the &lt;code&gt;INSERT&lt;/code&gt; waited. &lt;code&gt;pg_locks&lt;/code&gt; shows why: the &lt;code&gt;ALTER TABLE&lt;/code&gt; takes &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;, and the index build takes &lt;code&gt;SHARE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;How long they hold it grows with the table. On 2,000,000 rows, on a laptop, the &lt;code&gt;ADD CONSTRAINT&lt;/code&gt; took 2.9 seconds, and for those 2.9 seconds nothing could read &lt;code&gt;users&lt;/code&gt;. The build is a sort, so the time grows faster than the row count, and faster again once the sort no longer fits in &lt;code&gt;maintenance_work_mem&lt;/code&gt;: a table fifty times larger holds the lock for minutes, and every query that arrives in the meantime queues behind it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CREATE UNIQUE INDEX CONCURRENTLY&lt;/code&gt; takes &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt;, which lets both reads and writes through while it scans the table twice. It has two rules of its own. It cannot run inside a transaction block, so migration frameworks need their per-migration transaction turned off: &lt;code&gt;disable_ddl_transaction!&lt;/code&gt; with &lt;code&gt;algorithm: :concurrently&lt;/code&gt; in Rails, and &lt;code&gt;atomic = False&lt;/code&gt; in Django. Django's &lt;code&gt;AddIndexConcurrently&lt;/code&gt; cannot help here, because its &lt;code&gt;Index&lt;/code&gt; has no unique option, so a concurrent unique index in Django is a &lt;code&gt;RunSQL&lt;/code&gt; statement. And if it fails, it leaves the index behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I add a unique constraint to a large table without blocking?
&lt;/h2&gt;

&lt;p&gt;Build the index concurrently, then promote it. The &lt;a href="https://www.postgresql.org/docs/current/sql-altertable.html" rel="noopener noreferrer"&gt;ALTER TABLE documentation&lt;/a&gt; describes exactly this for "situations where a new constraint needs to be added without blocking table updates for a long time":&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;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;users_email_idx&lt;/span&gt; &lt;span class="k"&gt;ON&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;email&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;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;users_email_key&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;users_email_idx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second statement still takes &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;, but it does not scan anything: on the same 2,000,000 rows it took 0.8 milliseconds, against 2.9 seconds for the direct &lt;code&gt;ADD CONSTRAINT&lt;/code&gt;. PostgreSQL renames the index to the constraint name as it goes. The short lock still has to be granted, though, and it waits behind any open transaction that has touched the table, while every query that arrives after it waits too. Run &lt;code&gt;SET lock_timeout = '2s'&lt;/code&gt; before the &lt;code&gt;ALTER TABLE&lt;/code&gt; and retry if it times out, so a long-running report cannot turn a 0.8 millisecond step into an outage. The index must be valid, non-partial, not on an expression, and a B-tree with default sort order, so this route only ends in a constraint for a rule that could have been a constraint anyway. For a partial or expression rule, stop after the first statement.&lt;/p&gt;

&lt;p&gt;Before either statement, check for duplicates, because both fail on them:&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;email&lt;/span&gt;&lt;span class="p"&gt;,&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;users&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="k"&gt;HAVING&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="o"&gt;&amp;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;A failed plain &lt;code&gt;ADD CONSTRAINT&lt;/code&gt; rolls back cleanly with &lt;code&gt;could not create unique index "users_email_key"&lt;/code&gt; and a detail line naming the first duplicated key. A failed &lt;code&gt;CONCURRENTLY&lt;/code&gt; build reports the same error but leaves &lt;code&gt;users_email_idx&lt;/code&gt; in place with &lt;code&gt;indisvalid = false&lt;/code&gt;. The &lt;a href="https://www.postgresql.org/docs/current/sql-createindex.html" rel="noopener noreferrer"&gt;CREATE INDEX documentation&lt;/a&gt; is plain about the cost: it is ignored for queries "however it will still consume update overhead". Drop it, fix the data, and build again.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity shows the cost before the migration runs
&lt;/h2&gt;

&lt;p&gt;The point of reading the lock table above is to see the impact of every change before it reaches production, and a migration file does not show you any of it. &lt;code&gt;CREATE UNIQUE INDEX&lt;/code&gt; and &lt;code&gt;ADD CONSTRAINT ... UNIQUE&lt;/code&gt; are each one line, and nothing in the text says which one stops reads, or whether the data will let either succeed.&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;/p&gt;

&lt;p&gt;When a pending change adds uniqueness to a connected PostgreSQL table, &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; reports it twice. Under &lt;strong&gt;Can fail on existing data&lt;/strong&gt; it says "Adds a UNIQUE on users, fails if duplicates exist among ~2M rows", and for a rule on plain columns it adds a &lt;strong&gt;Count exactly&lt;/strong&gt; button that runs one read-only duplicate count under a timeout. Under &lt;strong&gt;Holds back other statements&lt;/strong&gt; it names which lock you will get: "Checking the new key holds back reads and writes to users while it reads ~2M rows" for a constraint, or "Building the index holds back writes to users while it reads ~2M rows" for a unique index, with the table's size on disk. The lock finding is skipped below 10,000 rows, where the lock is over before anyone waits on it. The same findings come up for a migration file written by Django, Rails, Prisma or an AI agent when you &lt;a href="https://schemity.com/blog/should-ai-agents-write-database-migrations/" rel="noopener noreferrer"&gt;analyse it against the connected database&lt;/a&gt; without running it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preview changes&lt;/strong&gt; shows the same two findings next to the statement that causes them, so the reader sees the &lt;code&gt;ALTER TABLE&lt;/code&gt; and its cost together:&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%2F8ssi1ahhmnyoj1sg6c3m.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%2F8ssi1ahhmnyoj1sg6c3m.webp" alt="Schemity's Preview changes findings drawer for a users table with 2 million rows: the planned migration ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email), one planned change, no lint findings, and two impact findings - adds a UNIQUE on users, fails if duplicates exist among ~2M rows, and checking the new key holds back reads and writes to users while it reads ~2M rows, 189 MB on disk - beside the users entity with a U marker on email" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Add the same rule as a unique index instead and the duplicate finding stays, but the lock finding drops "reads and": the index build holds back writes only.&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%2F0j5dlvqwqu3r46j1vcdm.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%2F0j5dlvqwqu3r46j1vcdm.webp" alt="The same Schemity findings drawer for a unique index on users (email): the planned migration CREATE UNIQUE INDEX users_email_idx ON users USING btree (email), one planned change reading unique index on users (email) added, no lint findings, and two impact findings - adds a UNIQUE on users, fails if duplicates exist among ~2M rows, and building the index holds back writes to users while it reads ~2M rows, 189 MB on disk - beside the users entity with a U marker on email and idx: 1 in its footer" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The limits are worth stating. Schemity's own &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL&lt;/a&gt; never uses &lt;code&gt;CONCURRENTLY&lt;/code&gt;, so on a big table the finding is your cue to run the two-statement recipe above in your migration tool instead. Schemity reads that recipe too: paste it into the &lt;strong&gt;SQL migration&lt;/strong&gt; drawer (Shift+F7) and both statements are analysed against the connected database, with nothing in the file executed.&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%2Fure8udmid9h2h9b5nhzh.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%2Fure8udmid9h2h9b5nhzh.webp" alt="Schemity's SQL to analyse dialog holding the two-statement recipe: CREATE UNIQUE INDEX CONCURRENTLY users_email_idx ON users (email), then ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE USING INDEX users_email_idx" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The report keeps the finding that matters and drops the one that no longer applies. The concurrent build can still fail on the duplicate, and &lt;strong&gt;Count exactly&lt;/strong&gt; finds exactly one row. There is no lock finding, because the build lets reads and writes through and the promotion reads no rows.&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%2Fu7lh0k7tj1ljb0jtkyn7.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%2Fu7lh0k7tj1ljb0jtkyn7.webp" alt="Schemity's SQL migration drawer after analysing the recipe: 2 statements, 2 analysed, 0 not analysed, 0 bookkeeping, and one finding under Can fail on existing data - adds a UNIQUE on users, fails if duplicates exist among ~2M rows - with Count exactly showing exactly 1 row, and the note that nothing in the file is executed" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the canvas, a column covered by a constraint or a plain unique index carries the same U marker, as described in &lt;a href="https://schemity.com/doc/check-constraints-composite-unique/" rel="noopener noreferrer"&gt;check constraints and composite unique&lt;/a&gt;. The diagram does not read a partial index's &lt;code&gt;WHERE&lt;/code&gt; clause, so a partial unique index shows as unique on its columns without its condition, and an index on an expression such as &lt;code&gt;lower(email)&lt;/code&gt; does not appear at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;p&gt;What a unique rule actually covers when one of its columns can be empty is in &lt;a href="https://schemity.com/blog/unique-constraints-and-nullable-columns/" rel="noopener noreferrer"&gt;unique constraints and nullable columns&lt;/a&gt;. For the checks a migration linter makes and a schema linter makes, see &lt;a href="https://schemity.com/blog/schema-linting-vs-migration-linting/" rel="noopener noreferrer"&gt;schema linting vs migration linting&lt;/a&gt;. And for another change that fails on data already in the table, see &lt;a href="https://schemity.com/blog/should-you-use-foreign-key-constraints/" rel="noopener noreferrer"&gt;when skipping foreign key constraints is right&lt;/a&gt;, which covers orphan rows that stop a new foreign key.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>migrations</category>
    </item>
    <item>
      <title>Polymorphic Associations in PostgreSQL: One commentable_id Column or a Foreign Key per Table?</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Fri, 25 Sep 2026 03:30:54 +0000</pubDate>
      <link>https://dev.to/tbson87/polymorphic-associations-in-postgresql-one-commentableid-column-or-a-foreign-key-per-table-5297</link>
      <guid>https://dev.to/tbson87/polymorphic-associations-in-postgresql-one-commentableid-column-or-a-foreign-key-per-table-5297</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 polymorphic association stores a type name and an id in two columns, and PostgreSQL has no foreign key that can point at a different table on each row, so nothing stops orphans or typos. For a small fixed set of parents, use one nullable foreign key per parent with a &lt;code&gt;CHECK&lt;/code&gt; that exactly one is set; for many parents, use a shared supertype table. Keep the polymorphic pair only when the set of parents is open-ended, and document it in the ERD as virtual relations, which is how Schemity draws it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In PostgreSQL, a polymorphic association is the one design where the database cannot help you: a foreign key names exactly one table, so a &lt;code&gt;commentable_id&lt;/code&gt; that points at &lt;code&gt;posts&lt;/code&gt; on one row and &lt;code&gt;photos&lt;/code&gt; on the next is checked by nothing. If the set of parents is small and fixed, use one foreign key per parent with a &lt;code&gt;CHECK&lt;/code&gt; that exactly one is set; if it is large, use a shared supertype table; keep the polymorphic pair only when the list of parents is genuinely open.&lt;/p&gt;

&lt;p&gt;Most schemas get a polymorphic association without anyone deciding on one. Rails has &lt;code&gt;belongs_to :commentable, polymorphic: true&lt;/code&gt;, Laravel has &lt;code&gt;morphTo&lt;/code&gt;, and Django has &lt;code&gt;GenericForeignKey&lt;/code&gt;, and each makes it one line of model code. The &lt;a href="https://guides.rubyonrails.org/association_basics.html#polymorphic-associations" rel="noopener noreferrer"&gt;Rails guide to polymorphic associations&lt;/a&gt; shows the migration it produces: an id column and a type column, with no foreign key between them and any other table.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a polymorphic association look like in PostgreSQL?
&lt;/h2&gt;

&lt;p&gt;Two columns on the child table, one holding a table or class name and one holding a primary key value from that table:&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;comments&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="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&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;commentable_type&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="c1"&gt;-- 'Post' or 'Photo'&lt;/span&gt;
    &lt;span class="n"&gt;commentable_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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;          &lt;span class="nb"&gt;text&lt;/span&gt;   &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="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="k"&gt;ON&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;commentable_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;commentable_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Django spells the same idea as &lt;code&gt;content_type_id&lt;/code&gt; (a real foreign key to &lt;code&gt;django_content_type&lt;/code&gt;) plus &lt;code&gt;object_id&lt;/code&gt;, and Laravel uses the &lt;code&gt;*_type&lt;/code&gt; / &lt;code&gt;*_id&lt;/code&gt; pair exactly as Rails does. In every version, the column that actually points at a parent row, &lt;code&gt;commentable_id&lt;/code&gt; or &lt;code&gt;object_id&lt;/code&gt;, carries no constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can't PostgreSQL put a foreign key on commentable_id?
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;REFERENCES&lt;/code&gt; takes one table. &lt;code&gt;commentable_id REFERENCES posts (id)&lt;/code&gt; would reject every comment on a photo, and there is no syntax for "the table named in the other column". Everything a foreign key normally does for you is gone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Orphans.&lt;/strong&gt; Deleting a post leaves its comments behind, because there is no &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; to fire. The cleanup lives in a model callback, which a bulk &lt;code&gt;DELETE&lt;/code&gt; in a console or a second service never runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dangling ids.&lt;/strong&gt; Nothing checks that post 4812 exists when a comment claims to belong to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bad type names.&lt;/strong&gt; &lt;code&gt;'Post'&lt;/code&gt;, &lt;code&gt;'post'&lt;/code&gt; and a class renamed in a refactor are all just text. After a rename, every old row points at a type the application no longer knows. A &lt;code&gt;CHECK (commentable_type IN ('Post', 'Photo'))&lt;/code&gt; closes the typo half of this, since PostgreSQL then rejects &lt;code&gt;'post'&lt;/code&gt;, but it still says nothing about whether the id exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invisible structure.&lt;/strong&gt; Every tool that reads relationships from the catalog, from ERD software to BI join suggestions, sees &lt;code&gt;comments&lt;/code&gt; as connected to nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What are the alternatives to a polymorphic association?
&lt;/h2&gt;

&lt;p&gt;There are three, and each one gives the database back a real foreign key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One foreign key per parent (an exclusive arc).&lt;/strong&gt; Each possible parent gets its own nullable column, and a &lt;code&gt;CHECK&lt;/code&gt; enforces that exactly one is set. PostgreSQL has had &lt;a href="https://www.postgresql.org/docs/current/functions-comparison.html" rel="noopener noreferrer"&gt;&lt;code&gt;num_nonnulls&lt;/code&gt;&lt;/a&gt; since 9.6, which makes the check one line:&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;comments&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="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&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="nb"&gt;bigint&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="n"&gt;photo_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;photos&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;content&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&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;num_nonnulls&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="n"&gt;photo_id&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A supertype table.&lt;/strong&gt; Create &lt;code&gt;commentables (id bigint PRIMARY KEY)&lt;/code&gt;, give &lt;code&gt;posts&lt;/code&gt; and &lt;code&gt;photos&lt;/code&gt; a primary key that is also a foreign key to it, and point &lt;code&gt;comments.commentable_id&lt;/code&gt; at &lt;code&gt;commentables&lt;/code&gt;. One foreign key, enforced, however many kinds of parent you add.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A comment table per parent.&lt;/strong&gt; &lt;code&gt;post_comments&lt;/code&gt; and &lt;code&gt;photo_comments&lt;/code&gt;, each with an ordinary foreign key. It is the simplest schema and the right one when comments on different parents carry different columns anyway.&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;Polymorphic type + id&lt;/th&gt;
&lt;th&gt;One FK per parent (exclusive arc)&lt;/th&gt;
&lt;th&gt;Supertype table&lt;/th&gt;
&lt;th&gt;Table per parent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reference enforced by PostgreSQL&lt;/td&gt;
&lt;td&gt;No&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;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ON DELETE CASCADE&lt;/code&gt; works&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, via the supertype&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong type name rejected&lt;/td&gt;
&lt;td&gt;Only with a &lt;code&gt;CHECK&lt;/code&gt; on the type column&lt;/td&gt;
&lt;td&gt;No type column to get wrong&lt;/td&gt;
&lt;td&gt;No type column to get wrong&lt;/td&gt;
&lt;td&gt;No type column to get wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adding a new parent type&lt;/td&gt;
&lt;td&gt;New type value, plus a &lt;code&gt;CHECK&lt;/code&gt; change if you added one&lt;/td&gt;
&lt;td&gt;New column plus a &lt;code&gt;CHECK&lt;/code&gt; change&lt;/td&gt;
&lt;td&gt;New table referencing the supertype&lt;/td&gt;
&lt;td&gt;New comment table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query "all comments on this row"&lt;/td&gt;
&lt;td&gt;Filter on two columns&lt;/td&gt;
&lt;td&gt;Filter on one column&lt;/td&gt;
&lt;td&gt;Filter on one column&lt;/td&gt;
&lt;td&gt;Query one table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nullable columns&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;All but one per row&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Should I use polymorphic associations in Postgres?
&lt;/h2&gt;

&lt;p&gt;Rarely, and on purpose when you do. A workable rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two to five fixed parents:&lt;/strong&gt; use the exclusive arc. The nullable columns are cheap, the &lt;code&gt;CHECK&lt;/code&gt; is one line, and every reference is enforced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many parents, or you need to list "everything that can be commented on":&lt;/strong&gt; use the supertype table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Children that differ per parent:&lt;/strong&gt; use a table per parent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An open-ended set of parents,&lt;/strong&gt; such as plugins, audit logs or activity feeds that attach to any table in the system: the polymorphic pair is a reasonable trade, as long as the application owns the integrity and the schema says so. Give the type column a &lt;code&gt;CHECK&lt;/code&gt; listing the allowed values, so at least a misspelled type cannot reach the table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last condition is the one teams skip. A polymorphic column that nobody has documented is a relationship that exists only in model code, and the next engineer reading the database finds a &lt;code&gt;bigint&lt;/code&gt; column that joins to nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you draw a polymorphic association in an ERD?
&lt;/h2&gt;

&lt;p&gt;When you design the next one, the diagram should show both kinds of reference honestly: the ones the database enforces and the ones it cannot. 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;A real relation in Schemity only draws what the database would accept, so there is no way to draw &lt;code&gt;commentable_id&lt;/code&gt; as a foreign key to two tables. What you draw instead is &lt;a href="https://schemity.com/doc/virtual-relations/" rel="noopener noreferrer"&gt;a virtual relation&lt;/a&gt;, and it takes four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Drag a relation from &lt;code&gt;posts&lt;/code&gt; to &lt;code&gt;comments&lt;/code&gt;, exactly as you would for a real foreign key.&lt;/li&gt;
&lt;li&gt;In the relation dialog, switch to the &lt;strong&gt;Virtual relation&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Pick the existing &lt;code&gt;commentable_id&lt;/code&gt; column on the &lt;code&gt;comments&lt;/code&gt; side. Nothing new is created: a virtual relation points at a column you already have.&lt;/li&gt;
&lt;li&gt;Type a description such as "Post comments" and click &lt;strong&gt;Save&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&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%2F42sq0esmojtxptbzprsh.gif" 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%2F42sq0esmojtxptbzprsh.gif" alt="Creating a virtual relation in Schemity: dragging a relation from posts to comments, switching the relation dialog to the Virtual relation tab, picking the existing commentable_id column, typing the description Post comments and saving, after which a dashed line labelled Post comments joins posts to comments" width="600" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Repeat it from &lt;code&gt;photos&lt;/code&gt; to the same &lt;code&gt;commentable_id&lt;/code&gt; column with "Photo comments". The diagram now shows &lt;code&gt;comments&lt;/code&gt; depending on both parents, with a dashed line for each, and neither line ever reaches a generated migration or a DBML export. Schemity draws each description along its line, so a reader knows what each dashed line means without opening a dialog. If the type column has a &lt;code&gt;CHECK (commentable_type IN ('Post', 'Photo'))&lt;/code&gt;, the column is underlined as an enum-like field and the entity footer counts the constraint, so the one guard the database does provide is visible too. Virtual relations survive a re-sync from the database, and the cardinality dialog hides &lt;code&gt;ON DELETE&lt;/code&gt; and &lt;code&gt;ON UPDATE&lt;/code&gt; for them, because nothing performs those actions.&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%2Fve44to6hs6m6bkop2slw.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%2Fve44to6hs6m6bkop2slw.webp" alt="A polymorphic association in Schemity: comments.commentable_id carries two dashed virtual relations, labelled Post comments and Photo comments, one to posts and one to photos, and the underlined commentable_type column carries a CHECK counted as cc: 1 in the footer" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you choose the exclusive arc instead, it draws as what it is: &lt;code&gt;post_id&lt;/code&gt; and &lt;code&gt;photo_id&lt;/code&gt; as real foreign keys, each with the green N badge that marks a nullable column, and each parent end drawn as optional, because any single comment belongs to only one of them, and the &lt;code&gt;num_nonnulls&lt;/code&gt; &lt;code&gt;CHECK&lt;/code&gt; counted in the entity footer. &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;Schema lint&lt;/a&gt; then flags either key if it has no index, which matters here because each parent's delete looks up its children through that column. Either way, the reader sees the real shape of the model, and the choice between enforced and documented is visible on the canvas rather than buried in a model file.&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%2Fhqzrbq2t2lpo2j48s1qj.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%2Fhqzrbq2t2lpo2j48s1qj.webp" alt="The exclusive arc in Schemity: comments has nullable post_id and photo_id columns marked with green N badges, each a real foreign key drawn as a solid line with an optional parent end, labelled Post comments and Photo comments, and the num_nonnulls CHECK counted as cc: 1 in the footer" width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;p&gt;The broader question of when a schema should rely on documented rather than declared references is covered in &lt;a href="https://schemity.com/blog/should-you-use-foreign-key-constraints/" rel="noopener noreferrer"&gt;when skipping foreign key constraints is right&lt;/a&gt;. For what a cascade does once it is declared, see &lt;a href="https://schemity.com/blog/on-delete-cascade-is-invisible-in-your-erd/" rel="noopener noreferrer"&gt;why ON DELETE CASCADE is invisible in most ERDs&lt;/a&gt;, and for the key type behind every &lt;code&gt;_id&lt;/code&gt; column in this post, &lt;a href="https://schemity.com/blog/postgres-uuid-vs-bigint-primary-key/" rel="noopener noreferrer"&gt;UUID vs bigint primary keys in PostgreSQL&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>sql</category>
      <category>rails</category>
    </item>
    <item>
      <title>Column Comments in PostgreSQL and MySQL: How to Document Columns Without a Migration</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Fri, 21 Aug 2026 03:21:45 +0000</pubDate>
      <link>https://dev.to/tbson87/column-comments-in-postgresql-and-mysql-how-to-document-columns-without-a-migration-2no0</link>
      <guid>https://dev.to/tbson87/column-comments-in-postgresql-and-mysql-how-to-document-columns-without-a-migration-2no0</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The database has a built-in place to document a column - COMMENT ON COLUMN in PostgreSQL, the COMMENT attribute in MySQL - and almost nobody fills it in, because a sentence of prose has to travel the same path as a schema change: a migration file, a review, a deploy. Schemity keeps field descriptions in the diagram instead, where editing one generates no SQL, reads existing database comments in on import, and exports the result as a data dictionary.&lt;/p&gt;
&lt;/blockquote&gt;

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;p&gt;In Schemity, &lt;a href="https://schemity.com/doc/tables-and-fields/" rel="noopener noreferrer"&gt;a field carries a description of its own&lt;/a&gt;, the same way entities and legends already &lt;a href="https://schemity.com/doc/legends-and-annotations/" rel="noopener noreferrer"&gt;carry markdown descriptions&lt;/a&gt;. It is a text box in the field editor, next to the type and the default, and the sentence it holds is the one a MySQL migration would have had to restate a whole column definition to attach.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd4dm1yv0o09xdcos4p2i.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd4dm1yv0o09xdcos4p2i.webp" alt="The field editor for articles.content in Schemity, with the Description box reading: String if we choose HTML WYSIWYG or Markdown editor. Use JSON if we choose structure content like Editor.js - alongside the field name, TEXT type, BLANK default and the PK, Unique and Nullable checkboxes" width="799" height="543"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;A documented field is then visible without opening anything: a bar on the leading edge of the row marks any field that has one, so which parts of a table are documented is a glance down a column rather than an audit, and the bar is drawn into SVG exports too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgelyk1x9i3weww47b9m0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgelyk1x9i3weww47b9m0.webp" alt="The articles entity on the Schemity canvas, nine fields listed, where only the content row carries a short pale bar on its leading edge marking it as the one field with a description" width="799" height="543"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

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

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

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

&lt;p&gt;If that is your goal, write the comments as migrations and treat them as schema changes, deliberately. What you should not do is adopt that cost by accident for documentation that only people will ever read, which is the situation almost every team is actually in.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Comment in the database&lt;/th&gt;
&lt;th&gt;Description in the model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read by&lt;/td&gt;
&lt;td&gt;Anything that introspects the catalog&lt;/td&gt;
&lt;td&gt;People, and any export you generate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost of an edit&lt;/td&gt;
&lt;td&gt;Migration, review, deploy&lt;/td&gt;
&lt;td&gt;Save the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL cost of an edit&lt;/td&gt;
&lt;td&gt;Restating the full column definition&lt;/td&gt;
&lt;td&gt;The same as any other engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survives a schema refresh&lt;/td&gt;
&lt;td&gt;Yes, it is the source&lt;/td&gt;
&lt;td&gt;Yes, it is not overwritten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reviewed in the pull request&lt;/td&gt;
&lt;td&gt;As DDL&lt;/td&gt;
&lt;td&gt;As a diff in the diagram JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

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

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

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

</description>
      <category>database</category>
      <category>documentation</category>
      <category>sql</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Circular Foreign Keys: Why the First Row Cannot Be Inserted</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Wed, 19 Aug 2026 01:48:01 +0000</pubDate>
      <link>https://dev.to/tbson87/circular-foreign-keys-why-the-first-row-cannot-be-inserted-30h5</link>
      <guid>https://dev.to/tbson87/circular-foreign-keys-why-the-first-row-cannot-be-inserted-30h5</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; When foreign keys form a cycle and every column in it is NOT NULL, each insert needs a row that does not exist yet, so an empty database can never take its first row. The cycle is a property of the whole graph rather than of any one relationship, so nobody spots it by looking at the diagram - it surfaces at seed time on a fresh environment. Schemity's fk-cycle-all-not-null lint rule computes it from the open ERD offline and marks the entities involved in the margin, and the fix is a modelling decision: make one side nullable, defer the check on an engine that can, or move the reference into a third table.&lt;/p&gt;
&lt;/blockquote&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;You compute it. &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;Schema lint&lt;/a&gt; in Schemity ships seventeen rules, and &lt;code&gt;fk-cycle-all-not-null&lt;/code&gt; is one of the three in the group that fails at runtime: a cycle of foreign keys in which every column is &lt;code&gt;NOT NULL&lt;/code&gt;, with a table referencing itself as the simplest case. It walks the relationships in the open diagram, so the three-table ring is found on exactly the same terms as the self-reference - the number of hops makes no difference to a graph traversal and all the difference to a person.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnba0041mhm1qe0zkf17g.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnba0041mhm1qe0zkf17g.webp" alt="Schemity's Lint panel with a Fails at runtime group holding two findings, table1 &gt; table3 &gt; table2 and table5 &gt; table6, each reporting that every foreign key in the cycle is NOT NULL so no row can be inserted, beside a canvas where table1, table2 and table3 form a ring of three ordinary-looking relationships" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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