<?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: Varun Krishnan</title>
    <description>The latest articles on DEV Community by Varun Krishnan (@not_varunkv).</description>
    <link>https://dev.to/not_varunkv</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%2F3608714%2F3d7fbcf6-347c-4905-b0ec-2b7ec91677b4.jpg</url>
      <title>DEV Community: Varun Krishnan</title>
      <link>https://dev.to/not_varunkv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/not_varunkv"/>
    <language>en</language>
    <item>
      <title>Django Auth Tables and Permissions Explained</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Thu, 10 Sep 2026 14:34:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/django-auth-tables-and-permissions-explained-44hd</link>
      <guid>https://dev.to/not_varunkv/django-auth-tables-and-permissions-explained-44hd</guid>
      <description>&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%2F0h5fwft8rsrpxchgjafe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0h5fwft8rsrpxchgjafe.png" alt="DJANGO AUTH Schema Diagram built on DBDiagramr" width="800" height="558"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Django creates 5 core auth tables: &lt;code&gt;auth_user&lt;/code&gt;, &lt;code&gt;auth_group&lt;/code&gt;, &lt;code&gt;auth_permission&lt;/code&gt;, &lt;code&gt;django_content_type&lt;/code&gt;, and two join tables (&lt;code&gt;auth_user_groups&lt;/code&gt;, &lt;code&gt;auth_group_permissions&lt;/code&gt;). The permission system is built on content types each model gets a default &lt;code&gt;add&lt;/code&gt;, &lt;code&gt;change&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;, and &lt;code&gt;view&lt;/code&gt; permission, and you can create custom ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core tables
&lt;/h2&gt;

&lt;h3&gt;
  
  
  auth_user
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int (PK)&lt;/td&gt;
&lt;td&gt;Auto-incrementing primary key.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;password&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(128)&lt;/td&gt;
&lt;td&gt;Hashed password (PBKDF2 by default).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;last_login&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;datetime&lt;/td&gt;
&lt;td&gt;When the user last logged in. Null if never.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;is_superuser&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bool&lt;/td&gt;
&lt;td&gt;Bypasses all permission checks.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;username&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(150)&lt;/td&gt;
&lt;td&gt;Unique username.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;first_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(150)&lt;/td&gt;
&lt;td&gt;User's first name.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;last_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(150)&lt;/td&gt;
&lt;td&gt;User's last name.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(254)&lt;/td&gt;
&lt;td&gt;Email address. Not necessarily unique.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;is_staff&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bool&lt;/td&gt;
&lt;td&gt;Can access the Django admin.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;is_active&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bool&lt;/td&gt;
&lt;td&gt;Set to False instead of deleting users.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;date_joined&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;datetime&lt;/td&gt;
&lt;td&gt;Registration time.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  auth_group
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int (PK)&lt;/td&gt;
&lt;td&gt;Auto-incrementing ID.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(150)&lt;/td&gt;
&lt;td&gt;Unique group name (e.g., "Editors", "Moderators").&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Groups are role containers. Assign permissions to groups, then add users to groups.&lt;/p&gt;

&lt;h3&gt;
  
  
  auth_permission
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int (PK)&lt;/td&gt;
&lt;td&gt;Auto-incrementing ID.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255)&lt;/td&gt;
&lt;td&gt;Human-readable name (e.g., "Can add post").&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;content_type_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int (FK)&lt;/td&gt;
&lt;td&gt;Which model this permission applies to.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;codename&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(100)&lt;/td&gt;
&lt;td&gt;Code identifier (e.g., &lt;code&gt;add_post&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each model gets 4 default permissions: &lt;code&gt;add_modelname&lt;/code&gt;, &lt;code&gt;change_modelname&lt;/code&gt;, &lt;code&gt;delete_modelname&lt;/code&gt;, &lt;code&gt;view_modelname&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  django_content_type
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int (PK)&lt;/td&gt;
&lt;td&gt;Auto-incrementing ID.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_label&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(100)&lt;/td&gt;
&lt;td&gt;The Django app (e.g., &lt;code&gt;blog&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;model&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(100)&lt;/td&gt;
&lt;td&gt;The model name (e.g., &lt;code&gt;post&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This table maps every model in your project to an ID. The permission system uses it to know which model a permission applies to.&lt;/p&gt;

&lt;h3&gt;
  
  
  auth_user_groups
&lt;/h3&gt;

&lt;p&gt;Join table for many-to-many: users ↔ groups.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int (PK)&lt;/td&gt;
&lt;td&gt;Auto-incrementing ID.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int (FK)&lt;/td&gt;
&lt;td&gt;→ &lt;code&gt;auth_user.id&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;group_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int (FK)&lt;/td&gt;
&lt;td&gt;→ &lt;code&gt;auth_group.id&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  auth_group_permissions
&lt;/h3&gt;

&lt;p&gt;Join table for many-to-many: groups ↔ permissions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int (PK)&lt;/td&gt;
&lt;td&gt;Auto-incrementing ID.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;group_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int (FK)&lt;/td&gt;
&lt;td&gt;→ &lt;code&gt;auth_group.id&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;permission_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int (FK)&lt;/td&gt;
&lt;td&gt;→ &lt;code&gt;auth_permission.id&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How permissions work
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User ──M──M── Group ──M──M── Permission ──M──1── ContentType
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;permission&lt;/strong&gt; is tied to a &lt;strong&gt;content type&lt;/strong&gt; (a specific model).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groups&lt;/strong&gt; collect permissions (e.g., "Editors" get &lt;code&gt;add_post&lt;/code&gt;, &lt;code&gt;change_post&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Users&lt;/strong&gt; are added to groups to inherit their permissions.&lt;/li&gt;
&lt;li&gt;You can also assign permissions directly to users (bypass groups).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Check permission
&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has_perm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;blog.add_post&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Add user to group
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.auth.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Group&lt;/span&gt;
&lt;span class="n"&gt;editors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Editors&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;editors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Create custom permission
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.auth.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Permission&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.contenttypes.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ContentType&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;blog.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Post&lt;/span&gt;
&lt;span class="n"&gt;content_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ContentType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_for_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;custom_perm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Permission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;codename&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;publish_post&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Can publish post&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What to change
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add a &lt;code&gt;role&lt;/code&gt; field&lt;/strong&gt; to &lt;code&gt;auth_user&lt;/code&gt; if you need simple role-based access (or use groups).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add an &lt;code&gt;avatar&lt;/code&gt; field&lt;/strong&gt; for profile pictures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create custom permissions&lt;/strong&gt; for fine-grained access control (e.g., &lt;code&gt;publish_post&lt;/code&gt;, &lt;code&gt;archive_post&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;unique=True&lt;/code&gt;&lt;/strong&gt; to &lt;code&gt;email&lt;/code&gt; if you want unique emails.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to leave alone
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't modify &lt;code&gt;django_content_type&lt;/code&gt; it's managed by Django's migration framework.&lt;/li&gt;
&lt;li&gt;Don't change &lt;code&gt;auth_permission.codename&lt;/code&gt; it's referenced by &lt;code&gt;has_perm()&lt;/code&gt; and decorators.&lt;/li&gt;
&lt;li&gt;Don't delete &lt;code&gt;auth_user.is_active&lt;/code&gt; use it instead of deleting users (preserves foreign keys).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Django create all these tables automatically?
&lt;/h3&gt;

&lt;p&gt;Yes. Running &lt;code&gt;python manage.py migrate&lt;/code&gt; creates all auth tables. They're part of Django's built-in &lt;code&gt;django.contrib.auth&lt;/code&gt; app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use Django without the permission system?
&lt;/h3&gt;

&lt;p&gt;Yes. Remove &lt;code&gt;django.contrib.auth&lt;/code&gt; from &lt;code&gt;INSTALLED_APPS&lt;/code&gt; and you lose the permission tables but keep the &lt;code&gt;auth_user&lt;/code&gt; model (or replace it entirely).&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between &lt;code&gt;is_superuser&lt;/code&gt; and &lt;code&gt;is_staff&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;is_superuser&lt;/code&gt; bypasses all permission checks. &lt;code&gt;is_staff&lt;/code&gt; only controls access to the Django admin. A superuser automatically has &lt;code&gt;is_staff=True&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I see my Django auth tables?
&lt;/h3&gt;

&lt;p&gt;Use &lt;a href="https://www.dbdiagramr.space" rel="noopener noreferrer"&gt;dbdiagramr&lt;/a&gt; paste your connection string and get a visual schema of your Django auth tables.&lt;/p&gt;

</description>
      <category>django</category>
      <category>database</category>
      <category>tutorial</category>
      <category>python</category>
    </item>
    <item>
      <title>NextAuth / Auth.js Database Schema Explained</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Mon, 07 Sep 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/nextauth-authjs-database-schema-explained-13d2</link>
      <guid>https://dev.to/not_varunkv/nextauth-authjs-database-schema-explained-13d2</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;NextAuth (now Auth.js) creates 4 tables in your database: &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;accounts&lt;/code&gt;, &lt;code&gt;sessions&lt;/code&gt;, and &lt;code&gt;verification_tokens&lt;/code&gt;. The &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;accounts&lt;/code&gt; tables have a one-to-one relationship via &lt;code&gt;accounts.user_id&lt;/code&gt;. Sessions link to users via &lt;code&gt;sessions.user_id&lt;/code&gt;. Verification tokens are short-lived and self-cleaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4 tables
&lt;/h2&gt;

&lt;h3&gt;
  
  
  users
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text / UUID&lt;/td&gt;
&lt;td&gt;Primary key. Generated by NextAuth.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;Display name from the OAuth provider (Google, GitHub, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;User's email. May be null if the provider doesn't share it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;email_verified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;When the email was verified. Null if never verified.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;Profile picture URL from the provider.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;When the user first signed in.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;updated_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;Last profile sync from the provider.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  accounts
&lt;/h3&gt;

&lt;p&gt;This table links a user to an OAuth provider. One user can have multiple accounts (e.g., Google + GitHub).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text / UUID&lt;/td&gt;
&lt;td&gt;Primary key.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;Foreign key → &lt;code&gt;users.id&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;Always &lt;code&gt;"oauth"&lt;/code&gt; or &lt;code&gt;"oidc"&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;provider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"google"&lt;/code&gt;, &lt;code&gt;"github"&lt;/code&gt;, &lt;code&gt;"discord"&lt;/code&gt;, etc.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;provider_account_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;The provider's unique ID for this user.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refresh_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;OAuth refresh token (encrypted in production).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;access_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;OAuth access token (encrypted in production).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expires_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;When the access token expires (Unix timestamp).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;token_type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;Usually &lt;code&gt;"Bearer"&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;scope&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;Permissions granted by the provider.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;OIDC ID token (if using OIDC).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session_state&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;Provider-specific session state.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  sessions
&lt;/h3&gt;

&lt;p&gt;Active sessions for each user. NextAuth creates a new row here on every sign-in.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text / UUID&lt;/td&gt;
&lt;td&gt;Primary key.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;The session token stored in the user's cookie.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;Foreign key → &lt;code&gt;users.id&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expires&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;When this session expires.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  verification_tokens
&lt;/h3&gt;

&lt;p&gt;Short-lived tokens for email verification, password reset, etc. Self-cleaning old tokens are deleted automatically.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;identifier&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;Email or user ID the token is for.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;The actual token value.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expires&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;When this token expires.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How they connect
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;users ──1──1── accounts
   │
   1
   │
   ∞
sessions

users ──1──∞── verification_tokens (via identifier)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;One user → one or more accounts (Google, GitHub, etc.)&lt;/li&gt;
&lt;li&gt;One user → many sessions (different devices/browsers)&lt;/li&gt;
&lt;li&gt;Verification tokens are temporary and don't have a foreign key&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to change
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add a &lt;code&gt;role&lt;/code&gt; column&lt;/strong&gt; to &lt;code&gt;users&lt;/code&gt; if you need role-based access control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a &lt;code&gt;phone_number&lt;/code&gt; column&lt;/strong&gt; to &lt;code&gt;users&lt;/code&gt; if you're using SMS auth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypt &lt;code&gt;access_token&lt;/code&gt; and &lt;code&gt;refresh_token&lt;/code&gt;&lt;/strong&gt; in production NextAuth doesn't do this by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to leave alone
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't modify the &lt;code&gt;verification_tokens&lt;/code&gt; table it's managed automatically.&lt;/li&gt;
&lt;li&gt;Don't change the &lt;code&gt;session_token&lt;/code&gt; format it's a signed JWT.&lt;/li&gt;
&lt;li&gt;Don't add indexes to &lt;code&gt;provider_account_id&lt;/code&gt; unless you're querying it directly (it's already unique).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does NextAuth store passwords?
&lt;/h3&gt;

&lt;p&gt;No. NextAuth is an OAuth-first library. It doesn't handle passwords. If you need email/password auth, use &lt;code&gt;next-auth/providers/credentials&lt;/code&gt; with bcrypt, or use a service like Clerk or Lucia.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I see what's in my NextAuth tables?
&lt;/h3&gt;

&lt;p&gt;Use &lt;a href="https://www.dbdiagramr.space" rel="noopener noreferrer"&gt;dbdiagramr&lt;/a&gt; paste your connection string and get a visual schema of your NextAuth tables in seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I add custom fields to the users table?
&lt;/h3&gt;

&lt;p&gt;Yes. Add columns to the &lt;code&gt;users&lt;/code&gt; table directly. NextAuth will ignore columns it doesn't know about, so you can safely add &lt;code&gt;role&lt;/code&gt;, &lt;code&gt;phone_number&lt;/code&gt;, &lt;code&gt;preferences&lt;/code&gt;, etc.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when a user deletes their account?
&lt;/h3&gt;

&lt;p&gt;NextAuth doesn't cascade deletes by default. You need to manually delete from &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;accounts&lt;/code&gt;, and &lt;code&gt;sessions&lt;/code&gt;. Or add &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; to your foreign key constraints.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Auth.js the same as NextAuth?
&lt;/h3&gt;

&lt;p&gt;Yes. Auth.js is the rebranded version of NextAuth. The database schema is identical. If you're on NextAuth v4, you're using the same tables.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>auth</category>
      <category>database</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Laravel Default Database Tables Explained</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Thu, 03 Sep 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/laravel-default-database-tables-explained-341n</link>
      <guid>https://dev.to/not_varunkv/laravel-default-database-tables-explained-341n</guid>
      <description>&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%2Fl09jga0j5lfykqnq4btj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl09jga0j5lfykqnq4btj.png" alt="Laravel Schema Diagram" width="799" height="499"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A fresh Laravel install creates 7-8 tables depending on your packages. The core ones are &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;password_resets&lt;/code&gt;, &lt;code&gt;failed_jobs&lt;/code&gt;, and &lt;code&gt;personal_access_tokens&lt;/code&gt;. The &lt;code&gt;cache&lt;/code&gt;, &lt;code&gt;sessions&lt;/code&gt;, &lt;code&gt;jobs&lt;/code&gt;, and &lt;code&gt;batches&lt;/code&gt; tables are only created if you run the corresponding Artisan commands. None of them are sacred -- you can rename, extend, or replace any of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core tables
&lt;/h2&gt;

&lt;h3&gt;
  
  
  users
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bigint (PK)&lt;/td&gt;
&lt;td&gt;Auto-incrementing primary key.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255)&lt;/td&gt;
&lt;td&gt;User's display name.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255)&lt;/td&gt;
&lt;td&gt;Unique email address.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;email_verified_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;When email was verified. Null if unverified.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;password&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255)&lt;/td&gt;
&lt;td&gt;Hashed password (bcrypt). Never store plain text.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;remember_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(100)&lt;/td&gt;
&lt;td&gt;Token for "remember me" functionality.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;Registration time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;updated_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;Last profile update.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  password_resets
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255)&lt;/td&gt;
&lt;td&gt;The email requesting a reset.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255)&lt;/td&gt;
&lt;td&gt;The reset token (hashed).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;When the token was generated.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This table is self-cleaning -- old tokens are garbage collected.&lt;/p&gt;

&lt;h3&gt;
  
  
  failed_jobs
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bigint (PK)&lt;/td&gt;
&lt;td&gt;Auto-incrementing ID.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uuid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255)&lt;/td&gt;
&lt;td&gt;Unique identifier for the job.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;connection&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;Queue connection that failed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;queue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;queue name&lt;/td&gt;
&lt;td&gt;Which queue the job was on.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;payload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;longText&lt;/td&gt;
&lt;td&gt;The job's serialized data.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exception&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;longText&lt;/td&gt;
&lt;td&gt;The full exception stack trace.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;failed_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;When it failed.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use this table to debug failed queue jobs. The &lt;code&gt;exception&lt;/code&gt; column has the full stack trace.&lt;/p&gt;

&lt;h3&gt;
  
  
  personal_access_tokens
&lt;/h3&gt;

&lt;p&gt;Created by Laravel Sanctum for API token authentication.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bigint (PK)&lt;/td&gt;
&lt;td&gt;Auto-incrementing ID.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tokenable_type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255)&lt;/td&gt;
&lt;td&gt;The model this token belongs to (e.g., &lt;code&gt;App\Models\User&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tokenable_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bigint&lt;/td&gt;
&lt;td&gt;The ID of that model.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255)&lt;/td&gt;
&lt;td&gt;Token name (e.g., "Mobile App").&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(60)&lt;/td&gt;
&lt;td&gt;The hashed token value.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;abilities&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;JSON array of allowed abilities (e.g., &lt;code&gt;["*"]&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;last_used_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;When the token was last used.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expires_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;When the token expires (null = never).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Optional tables
&lt;/h2&gt;

&lt;h3&gt;
  
  
  sessions
&lt;/h3&gt;

&lt;p&gt;Created by &lt;code&gt;php artisan session:table&lt;/code&gt;. Stores HTTP session data in the database instead of files.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255) (PK)&lt;/td&gt;
&lt;td&gt;Session ID (from the cookie).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bigint (FK)&lt;/td&gt;
&lt;td&gt;The user this session belongs to. Null for guests.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ip_address&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(45)&lt;/td&gt;
&lt;td&gt;Client IP.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user_agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;Browser user agent string.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;payload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;longText&lt;/td&gt;
&lt;td&gt;Serialized session data.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;last_activity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;Unix timestamp of last activity.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  cache
&lt;/h3&gt;

&lt;p&gt;Created by &lt;code&gt;php artisan cache:table&lt;/code&gt;. Stores key-value cache entries in the database.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255) (PK)&lt;/td&gt;
&lt;td&gt;Cache key.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;value&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;longText&lt;/td&gt;
&lt;td&gt;Serialized cache value.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expiration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;Unix timestamp when it expires.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  jobs
&lt;/h3&gt;

&lt;p&gt;Created by &lt;code&gt;php artisan queue:table&lt;/code&gt;. Stores pending queue jobs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bigint (PK)&lt;/td&gt;
&lt;td&gt;Auto-incrementing ID.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;queue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255)&lt;/td&gt;
&lt;td&gt;Queue name.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;payload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;longText&lt;/td&gt;
&lt;td&gt;Serialized job data.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;attempts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;How many times it's been tried.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reserved_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;When it was reserved by a worker.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;available_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;When it can be picked up.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;When it was dispatched.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  batches
&lt;/h3&gt;

&lt;p&gt;Created by &lt;code&gt;php artisan queue:batches-table&lt;/code&gt;. Tracks batch progress for parallel job processing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bigint (PK)&lt;/td&gt;
&lt;td&gt;Auto-incrementing ID.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;varchar(255)&lt;/td&gt;
&lt;td&gt;Batch name.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;total_jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;Total jobs in the batch.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pending_jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;Jobs still to run.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;failed_jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;Jobs that failed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;failed_job_ids&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;longText&lt;/td&gt;
&lt;td&gt;JSON array of failed job IDs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;options&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;longText&lt;/td&gt;
&lt;td&gt;Serialized batch options.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cancelled_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;When cancelled (null if not).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;When the batch was created.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;finished_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;When the batch completed.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What to change
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add a &lt;code&gt;role&lt;/code&gt; column&lt;/strong&gt; to &lt;code&gt;users&lt;/code&gt; for role-based access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a &lt;code&gt;phone_number&lt;/code&gt; column&lt;/strong&gt; for SMS auth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add an &lt;code&gt;avatar_url&lt;/code&gt; column&lt;/strong&gt; for profile pictures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rename &lt;code&gt;password_resets&lt;/code&gt;&lt;/strong&gt; to &lt;code&gt;password_reset_tokens&lt;/code&gt; (Laravel 8+ convention).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to leave alone
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't modify the &lt;code&gt;failed_jobs.exception&lt;/code&gt; column -- it needs to hold full stack traces.&lt;/li&gt;
&lt;li&gt;Don't remove &lt;code&gt;personal_access_tokens.tokenable_type&lt;/code&gt; -- it's a polymorphic relation.&lt;/li&gt;
&lt;li&gt;Don't add indexes to &lt;code&gt;cache.key&lt;/code&gt; -- it's already the primary key.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Laravel create all these tables automatically?
&lt;/h3&gt;

&lt;p&gt;No. Only &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;password_resets&lt;/code&gt;, and &lt;code&gt;failed_jobs&lt;/code&gt; are created by &lt;code&gt;php artisan migrate&lt;/code&gt;. The others (&lt;code&gt;sessions&lt;/code&gt;, &lt;code&gt;cache&lt;/code&gt;, &lt;code&gt;jobs&lt;/code&gt;, &lt;code&gt;batches&lt;/code&gt;) require separate Artisan commands.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use Redis instead of the database tables?
&lt;/h3&gt;

&lt;p&gt;Yes. Laravel supports Redis for sessions, cache, and queues out of the box. Switch your &lt;code&gt;.env&lt;/code&gt; driver to &lt;code&gt;redis&lt;/code&gt; and the database tables become unnecessary.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between &lt;code&gt;password_resets&lt;/code&gt; and &lt;code&gt;password_reset_tokens&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Same table, different names. Laravel 8+ renamed it to &lt;code&gt;password_reset_tokens&lt;/code&gt;. If you're on an older version, it's still &lt;code&gt;password_resets&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I visualize my Laravel schema?
&lt;/h3&gt;

&lt;p&gt;Use &lt;a href="https://www.dbdiagramr.space" rel="noopener noreferrer"&gt;dbdiagramr&lt;/a&gt; -- paste your connection string and get a visual schema of all your Laravel tables.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>database</category>
      <category>tutorial</category>
      <category>php</category>
    </item>
    <item>
      <title>What Is an ER Diagram and How to Read One</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Thu, 03 Sep 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/what-is-an-er-diagram-and-how-to-read-one-23h9</link>
      <guid>https://dev.to/not_varunkv/what-is-an-er-diagram-and-how-to-read-one-23h9</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;An entity-relationship diagram is a picture of your database's tables, columns, and connections. You read it left to right: boxes are tables, lines are foreign-key relationships, and the symbols on each end tell you one-to-one, one-to-many, or many-to-many.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each part means
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Entities (tables)
&lt;/h3&gt;

&lt;p&gt;Every box is a table. The bold name on top is the table name. The list underneath is the columns. Primary keys are usually marked with a key icon or bolded. Foreign keys have a link icon.&lt;/p&gt;

&lt;p&gt;Example:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;users&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;id&lt;/strong&gt; (PK)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;created_at&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Attributes (columns)
&lt;/h3&gt;

&lt;p&gt;Each line under the table name is a column. Common types you'll see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;id&lt;/code&gt; -- primary key (unique identifier)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt; -- simple text fields&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;created_at&lt;/code&gt; -- timestamp of when the row was inserted&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;user_id&lt;/code&gt; -- foreign key pointing to another table (look for the line connecting it)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Relationships (lines)
&lt;/h3&gt;

&lt;p&gt;Lines between boxes represent foreign key references. If you see a line from &lt;code&gt;orders.user_id&lt;/code&gt; to &lt;code&gt;users.id&lt;/code&gt;, that means "each order belongs to one user."&lt;/p&gt;

&lt;h3&gt;
  
  
  Cardinality (symbols)
&lt;/h3&gt;

&lt;p&gt;The symbols at the end of each line tell you how many:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 -- ∞&lt;/strong&gt; (one to many): one user has many orders. This is the most common.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 -- 1&lt;/strong&gt; (one to one): one user has one profile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;∞ -- ∞&lt;/strong&gt; (many to many): many users have many roles. Usually resolved with a join table.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reading a real example
&lt;/h2&gt;

&lt;p&gt;Suppose you see this diagram:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;users ──1──∞── orders ──1──∞── order_items ──∞──1── products
   │
   1
   │
   ∞
addresses
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading left to right:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;One &lt;strong&gt;user&lt;/strong&gt; has many &lt;strong&gt;orders&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;One &lt;strong&gt;order&lt;/strong&gt; has many &lt;strong&gt;order items&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;One &lt;strong&gt;product&lt;/strong&gt; appears in many &lt;strong&gt;order items&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;One &lt;strong&gt;user&lt;/strong&gt; has many &lt;strong&gt;addresses&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The join table here is &lt;code&gt;order_items&lt;/code&gt; ,it connects &lt;code&gt;orders&lt;/code&gt; and &lt;code&gt;products&lt;/code&gt; in a many-to-many relationship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why ER diagrams matter
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Debugging&lt;/strong&gt;: "Why is my query returning duplicates?" look for a missing join or unintended many-to-many.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Onboarding&lt;/strong&gt;: New engineers understand the data model in 5 minutes instead of reading migration files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema reviews&lt;/strong&gt;: Spot missing indexes, redundant tables, or circular dependencies before they hit production.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Assuming every line is one-to-many.&lt;/strong&gt; Check the symbols. A many-to-many without a join table is a red flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring nullable columns.&lt;/strong&gt; A dashed line or optional symbol means the relationship is optional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting to check the foreign key direction.&lt;/strong&gt; &lt;code&gt;orders.user_id → users.id&lt;/code&gt; is different from &lt;code&gt;users.order_id → orders.id&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;Open your own database schema in &lt;a href="https://www.dbdiagramr.space" rel="noopener noreferrer"&gt;dbdiagramr&lt;/a&gt; and trace the foreign keys. Start from the table you're most familiar with and follow the lines outward.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What does PK mean in an ER diagram?
&lt;/h3&gt;

&lt;p&gt;PK stands for primary key the unique identifier for each row in a table. It's usually &lt;code&gt;id&lt;/code&gt; and is referenced by foreign keys in other tables.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does FK mean?
&lt;/h3&gt;

&lt;p&gt;FK stands for foreign key a column in one table that points to the primary key of another table. It creates the relationship between the two tables.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you show a many-to-many relationship?
&lt;/h3&gt;

&lt;p&gt;With a join table. For example, &lt;code&gt;users&lt;/code&gt; ↔ &lt;code&gt;user_roles&lt;/code&gt; ↔ &lt;code&gt;roles&lt;/code&gt;. The &lt;code&gt;user_roles&lt;/code&gt; table holds &lt;code&gt;user_id&lt;/code&gt; and &lt;code&gt;role_id&lt;/code&gt; foreign keys, and the many-to-many becomes two one-to-many relationships.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between an ER diagram and a schema diagram?
&lt;/h3&gt;

&lt;p&gt;They're the same thing in practice. ER diagram is the academic term; schema diagram is the engineering term. Both show tables, columns, and relationships.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to draw an ER diagram by hand?
&lt;/h3&gt;

&lt;p&gt;No. Tools like &lt;a href="https://www.dbdiagramr.space" rel="noopener noreferrer"&gt;dbdiagramr&lt;/a&gt; generate ER diagrams automatically from your Supabase, Neon, or PostgreSQL connection string. Paste your connection string and get a visual schema in seconds.&lt;/p&gt;

</description>
      <category>database</category>
      <category>tutorial</category>
      <category>postgres</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Supabase Auth Schema Explained: Users, Identities, Sessions</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Tue, 25 Aug 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/supabase-auth-schema-explained-users-identities-sessions-5b88</link>
      <guid>https://dev.to/not_varunkv/supabase-auth-schema-explained-users-identities-sessions-5b88</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Supabase stores auth in a separate &lt;code&gt;auth&lt;/code&gt; schema not your &lt;code&gt;public&lt;/code&gt; schema. Every user has exactly &lt;strong&gt;one row in &lt;code&gt;auth.users&lt;/code&gt;&lt;/strong&gt;, one or more rows in &lt;strong&gt;&lt;code&gt;auth.identities&lt;/code&gt;&lt;/strong&gt; (one per login provider), and can hold &lt;strong&gt;multiple active &lt;code&gt;auth.sessions&lt;/code&gt;&lt;/strong&gt;, each backed by a &lt;code&gt;refresh_tokens&lt;/code&gt; row. If you've ever poked at a Supabase database and seen a cluster of tables you didn't create, these are the ones, and this is what they do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;auth.users 1───* auth.identities
auth.users 1───* auth.sessions
auth.sessions 1───* auth.refresh_tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why an &lt;code&gt;auth&lt;/code&gt; schema at all
&lt;/h2&gt;

&lt;p&gt;Supabase deliberately keeps auth separate from your app's tables. Your &lt;code&gt;public&lt;/code&gt; schema is where your own models live; &lt;code&gt;auth&lt;/code&gt; is locked down and managed by the auth service (GoTrue). You read from it, you don't write to it. That separation is why your migrations never touch these tables and why introspecting a Supabase database shows you a schema you didn't write.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four tables that matter
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;auth.users&lt;/code&gt; one row per user.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;What it holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UUID primary key; the user's stable identifier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;email&lt;/code&gt; / &lt;code&gt;phone&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Contact + login identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;encrypted_password&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bcrypt hash (only for email/password auth)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;raw_app_meta_data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Provider claims: &lt;code&gt;provider&lt;/code&gt;, &lt;code&gt;providers&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;raw_user_meta_data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom metadata you set on the user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;is_sso_user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;True when sign-in came through SSO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;confirmed_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When the primary identity was confirmed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;auth.identities&lt;/code&gt; - one row per login method.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A user signing in with email &lt;strong&gt;and&lt;/strong&gt; GitHub gets two rows here. &lt;code&gt;provider_id&lt;/code&gt; is the provider's identifier for that user; &lt;code&gt;identity_data&lt;/code&gt; is the raw claim payload (name, avatar, email) the provider returned. The FK &lt;code&gt;user_id → users.id&lt;/code&gt; is what joins them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;auth.sessions&lt;/code&gt; - a browser/token session.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One session per logged-in device roughly. &lt;code&gt;aal&lt;/code&gt; (assurance level), &lt;code&gt;user_agent&lt;/code&gt;, &lt;code&gt;ip&lt;/code&gt;, and &lt;code&gt;not_after&lt;/code&gt; all live here. &lt;code&gt;factor_id&lt;/code&gt; links to MFA factors when you have TOTP enabled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;auth.refresh_tokens&lt;/code&gt; - the long-lived token backing a session.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Access tokens are short (JWT, ~1h). Refresh tokens are long and stored here, &lt;code&gt;revoked&lt;/code&gt; flag included, with &lt;code&gt;parent&lt;/code&gt; used to detect token reuse and rotate.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they relate (the joins you'll actually write)
&lt;/h2&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;u&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="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&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;as&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identities&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&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;join&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessions&lt;/span&gt;  &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That query is the 80% case: "who is signed in, through which provider, on which sessions." Every relationship is a plain foreign key &lt;code&gt;identities.user_id&lt;/code&gt;, &lt;code&gt;sessions.user_id&lt;/code&gt;, &lt;code&gt;refresh_tokens.session_id&lt;/code&gt; which is exactly what a schema diagram turns into readable arrows. If you'd rather trace them visually, paste a read-only connection string into &lt;a href="https://dbdiagramr.space/visualize" rel="noopener noreferrer"&gt;dbdiagramr&lt;/a&gt; and it will pull and render the same tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's usually NOT your business
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;auth.instances&lt;/code&gt;, &lt;code&gt;auth.audit_log_entries&lt;/code&gt;, and &lt;code&gt;auth.schema_migrations&lt;/code&gt; are Supabase's own bookkeeping. &lt;code&gt;audit_log_entries&lt;/code&gt; records admin actions inside the auth service; &lt;code&gt;instances&lt;/code&gt; is leftover multi-tenancy plumbing. If a diagram shows them, ignore them your reads live in the other four.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never write to &lt;code&gt;auth&lt;/code&gt; tables directly.&lt;/strong&gt; Use the Supabase client / Admin API. Direct inserts create inconsistent state (orphaned identities, unhashed passwords).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreign-key joins work across schemas.&lt;/strong&gt; &lt;code&gt;auth.users.id&lt;/code&gt; is the same UUID you'd use in &lt;code&gt;public&lt;/code&gt; join &lt;code&gt;public.profiles.user_id → auth.users.id&lt;/code&gt; for your own profile data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A user with no identity row is a sign of trouble.&lt;/strong&gt; Every normal user has at least one. Orphaned &lt;code&gt;identities&lt;/code&gt; without a &lt;code&gt;users&lt;/code&gt; row point at a cleanup/import bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions accumulate.&lt;/strong&gt; Old sessions linger; your diagram showing many sessions per user isn't a leak, it's devices and tabs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This whole picture is easier to keep straight when you can see it. The &lt;a href="https://dbdiagramr.space/schema/supabase" rel="noopener noreferrer"&gt;Supabase auth schema diagram&lt;/a&gt; was generated by introspecting a live Supabase database users, identities, sessions, and refresh_tokens with their foreign keys rendered as relationships. Open it next time you're debugging a sign-in flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Where is the Supabase auth schema?&lt;/strong&gt;&lt;br&gt;
In the &lt;code&gt;auth&lt;/code&gt; schema, separate from your &lt;code&gt;public&lt;/code&gt; schema &lt;code&gt;auth.users&lt;/code&gt;, &lt;code&gt;auth.identities&lt;/code&gt;, &lt;code&gt;auth.sessions&lt;/code&gt;, &lt;code&gt;auth.refresh_tokens&lt;/code&gt;, plus internal tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is &lt;code&gt;auth.users&lt;/code&gt; used for?&lt;/strong&gt;&lt;br&gt;
It's the single source of truth for who can sign in. One row per user, with email/phone, password hash, metadata, and provider flags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does one user have multiple identities?&lt;/strong&gt;&lt;br&gt;
Each login method is a separate &lt;code&gt;auth.identities&lt;/code&gt; row. Email + GitHub + Google = three rows, all pointing at the same &lt;code&gt;users.id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between a session and a refresh token?&lt;/strong&gt;&lt;br&gt;
A session is the device/token context; the refresh token is the long-lived credential that renews the short-lived JWT. One session maps to one refresh token chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I join auth tables to my public tables?&lt;/strong&gt;&lt;br&gt;
Yes the auth schema isn't isolated for queries. Use &lt;code&gt;auth.users.id&lt;/code&gt; as the join key with your &lt;code&gt;public.*&lt;/code&gt; tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;Live: &lt;a href="https://dbdiagramr.space" rel="noopener noreferrer"&gt;https://dbdiagramr.space&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/VarunKvK/dbdiagramr" rel="noopener noreferrer"&gt;https://github.com/VarunKvK/dbdiagramr&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If this is useful to you, a GitHub star helps a solo dev keep building in public. I started this tool because I kept needing to visualize exactly these tables after reading migrations.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>database</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>PostgreSQL Connection String: Supabase, Neon, Railway</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Thu, 20 Aug 2026 14:30:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/postgresql-connection-string-explained-supabase-neon-railway-2h7o</link>
      <guid>https://dev.to/not_varunkv/postgresql-connection-string-explained-supabase-neon-railway-2h7o</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;A PostgreSQL connection string is a single URL that tells a client how to reach your database. It looks like 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="n"&gt;postgresql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="n"&gt;YOUR&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;PASSWORD&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;abcdefghij&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;co&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5432&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;postgres&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every provider builds on the same format, and the differences that trip people up are almost never the syntax. They're the &lt;strong&gt;port and host&lt;/strong&gt; your provider hands you. Supabase alone has three different strings for the same database, and two of them won't work from serverless apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a connection string
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgresql://  user  :  password   @     host        :  port  /  database
     |          |         |              |              |          |
  protocol    username  secret         server        port       db name
&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;Part&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Protocol&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql://&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;postgres://&lt;/code&gt; also works in most clients&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;In Supabase: &lt;code&gt;postgres.[project-ref]&lt;/code&gt; on pooler URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[your-password]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;URL-encode special characters (&lt;code&gt;@&lt;/code&gt; → &lt;code&gt;%40&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;&lt;code&gt;db.xxxx.supabase.co&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The server; provider-specific&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5432&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6543 = transaction pooler&lt;/strong&gt; (Supabase/Neon)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Default database name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can append query parameters after the database. The two you'll actually meet are &lt;code&gt;?sslmode=require&lt;/code&gt; (force TLS) and &lt;code&gt;?channel_binding=require&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supabase: three strings for one database
&lt;/h2&gt;

&lt;p&gt;Supabase is where most people hit the connection-string wall, because the &lt;em&gt;right&lt;/em&gt; string depends entirely on where your code runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Direct&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;db.[project-ref].supabase.co&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5432&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Migrations, &lt;code&gt;pg_dump&lt;/code&gt;, long-lived backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shared pooler (session)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aws-[region].pooler.supabase.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5432&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent backend on IPv4-only networks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shared pooler (transaction)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aws-[region].pooler.supabase.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;6543&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Serverless / edge / short-lived connections&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Direct
postgresql://postgres:[YOUR-PASSWORD]@db.[project-ref].supabase.co:5432/postgres

# Shared pooler session mode
postgres://postgres.[project-ref]:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:5432/postgres

# Shared pooler transaction mode (the one most apps want)
postgres://postgres.[project-ref]:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:6543/postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The gotcha:&lt;/strong&gt; direct connections run on &lt;strong&gt;IPv6&lt;/strong&gt;. On an IPv4-only network you get an &lt;code&gt;ENOTFOUND&lt;/code&gt;, not a friendly error, just a silent failure to connect. The shared pooler is IPv4-only and fixes it. And if your app is serverless (Vercel functions, edge runtimes, Lambda), use &lt;strong&gt;transaction mode (port 6543)&lt;/strong&gt;. It's built for the many-short-connections pattern serverless forces.&lt;/p&gt;

&lt;p&gt;This exact confusion is why tools that accept a connection string usually ask you to grab the &lt;em&gt;transaction pooler&lt;/em&gt; URL. &lt;a href="https://dbdiagramr.space/visualize" rel="noopener noreferrer"&gt;dbdiagramr&lt;/a&gt;, for instance, has a step in its how-it-works guide telling you to select the Transaction pooler in the Supabase dashboard before pasting. Otherwise the connection silently fails from IPv4-only hosts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neon: pooled vs direct
&lt;/h2&gt;

&lt;p&gt;Neon makes the two variants explicit in the hostname:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Pooled (through PgBouncer, use by default)
postgresql://user:pass@ep-cool-rain-123456-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require

# Direct
postgresql://user:pass@ep-cool-rain-123456.us-east-2.aws.neon.tech/neondb?sslmode=require
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule of thumb: use the &lt;strong&gt;&lt;code&gt;-pooler&lt;/code&gt;&lt;/strong&gt; host unless you have a specific reason not to. It handles thousands of concurrent clients and is the right call for serverless. Grab both from the &lt;em&gt;Connect&lt;/em&gt; button in the Neon dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Railway: the plain standard
&lt;/h2&gt;

&lt;p&gt;Railway gives you a no-frills connection string with no pooler decision to make:&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;postgresql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="n"&gt;YOUR&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;PASSWORD&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;host&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;railway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5432&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;railway&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the textbook format, which makes it the easiest one to read and the easiest to misplace a password in. Store it in Railway's &lt;code&gt;Variables&lt;/code&gt; tab as &lt;code&gt;DATABASE_URL&lt;/code&gt;, never in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  All the formats in one table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Connection string shape&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standard / Railway&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql://user:pass@host:5432/db&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase (direct)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql://postgres:pass@db.[ref].supabase.co:5432/postgres&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase (session pooler)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres://postgres.[ref]:pass@aws-[region].pooler.supabase.com:5432/postgres&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase (transaction pooler)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres://postgres.[ref]:pass@aws-[region].pooler.supabase.com:6543/postgres&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neon (pooled)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql://user:pass@ep-xxx-pooler.region.aws.neon.tech/db?sslmode=require&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neon (direct)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql://user:pass@ep-xxx.region.aws.neon.tech/db?sslmode=require&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Gotchas that will actually bite you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IPv4 vs IPv6.&lt;/strong&gt; Supabase direct connections are IPv6. If you're on an IPv4-only network or using a tool on an IPv4-only host, you'll get &lt;code&gt;ENOTFOUND&lt;/code&gt; and no obvious reason why. The pooler strings are IPv4 and sidestep it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serverless wants the transaction pooler.&lt;/strong&gt; Long-lived backends can hold a connection. Serverless functions can't. Each invocation opens a new one. Session-pooling chokes on that pattern; transaction pooling (port &lt;strong&gt;6543&lt;/strong&gt;) exists for exactly it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A connection string is a secret.&lt;/strong&gt; It contains your password in plain text. Never commit it, never paste it into a shared doc, and use a pooler/proxy string when a third-party tool needs it. Most good tools never store it beyond the one request. (dbdiagramr introspects your schema and discards the string immediately.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL-encode passwords.&lt;/strong&gt; A password containing &lt;code&gt;@&lt;/code&gt;, &lt;code&gt;:&lt;/code&gt;, or &lt;code&gt;/&lt;/code&gt; breaks the URL. Encode those characters (&lt;code&gt;@&lt;/code&gt; → &lt;code&gt;%40&lt;/code&gt;) before pasting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Curious what that pooler URL actually contains after it connects? The &lt;a href="https://dbdiagramr.space/schema/supabase" rel="noopener noreferrer"&gt;Supabase auth schema diagram&lt;/a&gt; was created by introspecting a live Supabase database through a transaction pooler string. Users, identities, sessions, and refresh_tokens rendered as relationships.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is a PostgreSQL connection string?&lt;/strong&gt;&lt;br&gt;
It's a single URL containing everything needed to reach a Postgres database: protocol, username, password, host, port, and database name (e.g. &lt;code&gt;postgresql://user:pass@host:5432/db&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use a direct connection or a pooler?&lt;/strong&gt;&lt;br&gt;
For a persistent backend that can hold a connection open, direct is fine. For serverless or edge functions that open a connection per invocation, use a pooler, either Supabase transaction mode (port 6543) or a Neon &lt;code&gt;-pooler&lt;/code&gt; host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my Supabase connection fail with ENOTFOUND?&lt;/strong&gt;&lt;br&gt;
You're probably using the direct connection string over IPv4, and Supabase's direct endpoint is IPv6. Switch to a shared pooler string (&lt;code&gt;aws-[region].pooler.supabase.com&lt;/code&gt;) or add the IPv4 add-on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is my connection string a secret?&lt;/strong&gt;&lt;br&gt;
Yes, it's your password in plain text. Keep it in environment variables, rotate it if it leaks, and only hand it to tools that won't store it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;Live: &lt;a href="https://dbdiagramr.space" rel="noopener noreferrer"&gt;https://dbdiagramr.space&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/VarunKvK/dbdiagramr" rel="noopener noreferrer"&gt;https://github.com/VarunKvK/dbdiagramr&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If this is useful to you, a GitHub star helps a solo dev keep building in public. I hit the IPv6 wall the first time I tried to visualize a Supabase database from a serverless app. Figured others would save the hour.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Visualize a PostgreSQL Schema: 5 Ways Compared</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Tue, 18 Aug 2026 14:30:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/how-to-visualize-a-postgre-schema-5-ways-compared-35od</link>
      <guid>https://dev.to/not_varunkv/how-to-visualize-a-postgre-schema-5-ways-compared-35od</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Your Postgres schema is perfectly readable to the database and almost unreadable to a human. The tables, columns, and foreign keys you need are spread across &lt;code&gt;pg_catalog&lt;/code&gt;, &lt;code&gt;information_schema&lt;/code&gt;, and a few hundred lines of migration SQL. Visualization means collapsing all of that into a picture you can actually reason about.&lt;/p&gt;

&lt;p&gt;There are five practical ways to get there. They are not equivalent. Each makes a different trade between speed, depth, and how current the diagram stays.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five ways at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Interactive&lt;/th&gt;
&lt;th&gt;Stays current&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;psql&lt;/code&gt; + &lt;code&gt;pg_catalog&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Always (it's live)&lt;/td&gt;
&lt;td&gt;Quick inspection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IDE built-ins (pgAdmin, DBeaver)&lt;/td&gt;
&lt;td&gt;Already installed&lt;/td&gt;
&lt;td&gt;Partly&lt;/td&gt;
&lt;td&gt;Snapshot, manual re-run&lt;/td&gt;
&lt;td&gt;One-off look&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual diagramming (draw.io, dbdiagram.io)&lt;/td&gt;
&lt;td&gt;Short&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Drifts (you maintain it)&lt;/td&gt;
&lt;td&gt;Designing a new schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrations → DDL (Prisma, Rails)&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Re-run on change&lt;/td&gt;
&lt;td&gt;Documenting from code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live connection string (dbdiagramr)&lt;/td&gt;
&lt;td&gt;None (paste URL)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Regenerate in 10 seconds&lt;/td&gt;
&lt;td&gt;Understanding a real DB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. psql and pg_catalog: the baseline
&lt;/h2&gt;

&lt;p&gt;The fastest text-only view is &lt;code&gt;psql&lt;/code&gt;. &lt;code&gt;\dt&lt;/code&gt; lists all tables, &lt;code&gt;\d table_name&lt;/code&gt; shows one table's structure including its foreign keys. To see every relationship in one shot, query &lt;code&gt;information_schema&lt;/code&gt; directly:&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;tc&lt;/span&gt;&lt;span class="p"&gt;.&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;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ccu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;foreign_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ccu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;foreign_column_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_constraints&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_column_usage&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_column_usage&lt;/span&gt; &lt;span class="n"&gt;ccu&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ccu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'FOREIGN KEY'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Precise and always available. But it's a list, not a picture. Past a dozen tables you're reconstructing the graph in your head, which is exactly what visualization is supposed to remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. IDE built-ins: pgAdmin, DBeaver, DataGrip
&lt;/h2&gt;

&lt;p&gt;If you already have a GUI client, it probably ships with an ER diagram generator:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pgAdmin 4&lt;/strong&gt;: right-click the database → &lt;em&gt;ERD For Database&lt;/em&gt;. Free, already installed. Auto-layout struggles past a few dozen tables, and it regenerates by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DBeaver&lt;/strong&gt; (Community is enough): open the &lt;em&gt;ER Diagram&lt;/em&gt; tab. Genuinely free, cross-platform, exports PNG/SVG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DataGrip&lt;/strong&gt;: &lt;em&gt;Diagrams → Show Visualization&lt;/em&gt;. The best-feeling interactive diagram of the three; paid, single-user.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest limitation they all share: &lt;strong&gt;any IDE-based diagram is a snapshot&lt;/strong&gt;. Nothing to share but an exported image, and it drifts the moment someone runs a migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Manual diagramming: draw.io, dbdiagram.io, DrawSQL
&lt;/h2&gt;

&lt;p&gt;The classic approach, and great for designing a schema you haven't built yet. dbdiagram.io and DrawSQL are excellent editors. You write DBML or drag tables, and get a clean diagram you can export, share, and version.&lt;/p&gt;

&lt;p&gt;The catch is &lt;strong&gt;drift&lt;/strong&gt;. The diagram is a hand-made copy of the schema at one moment in time. The day someone adds a column or a foreign key, the diagram is wrong. A wrong diagram is worse than none, because people trust it. Building it from scratch takes 30 minutes to an hour, and it's stale the moment a migration lands.&lt;/p&gt;

&lt;p&gt;Use these when you're &lt;em&gt;designing&lt;/em&gt;. They struggle to &lt;em&gt;document a real schema over time&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Generate from your migrations
&lt;/h2&gt;

&lt;p&gt;If your schema lives in migration files (Prisma, Drizzle, Rails, Flyway), you can derive the structure from code instead of a live connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Dump structure only, never row data&lt;/span&gt;
pg_dump &lt;span class="nt"&gt;--schema-only&lt;/span&gt; mydb &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; schema.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then feed the DDL to any SQL-to-diagram tool. This is the right path when you can't or don't want to expose credentials. The trade-off: it documents the migrations, not necessarily what's actually deployed. Regenerating on every change is automation you have to build and maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Paste a live connection string: the "always current in 10 seconds" option
&lt;/h2&gt;

&lt;p&gt;The approach that solves both accuracy and effort: point a tool at the &lt;strong&gt;real database&lt;/strong&gt; and let it introspect the schema itself. No schema code, no hand-arranging, nothing to keep in sync. The diagram &lt;em&gt;is&lt;/em&gt; what's in your database right now.&lt;/p&gt;

&lt;p&gt;That's exactly what &lt;a href="https://dbdiagramr.space" rel="noopener noreferrer"&gt;dbdiagramr&lt;/a&gt; does. You &lt;a href="https://dbdiagramr.space/visualize" rel="noopener noreferrer"&gt;paste a PostgreSQL connection string into it&lt;/a&gt; and it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connects and reads the schema, &lt;em&gt;structure only, never your row data&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Queries &lt;code&gt;information_schema&lt;/code&gt; for tables, columns, primary keys, and foreign keys&lt;/li&gt;
&lt;li&gt;Renders an interactive ER diagram you can pan, zoom, drag tables around, and hover over to trace relationships&lt;/li&gt;
&lt;li&gt;Exports as SVG or PNG&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because it introspects the live database, there's no drift to manage. Change a migration, paste the same string again, and you have an up-to-date diagram in under 10 seconds. Your connection string is never stored. It's introspected and discarded immediately.&lt;/p&gt;

&lt;p&gt;Not sure what an ER diagram even looks like yet? The &lt;a href="https://dbdiagramr.space/schema" rel="noopener noreferrer"&gt;schema library&lt;/a&gt; has live diagrams of the Supabase auth, NextAuth.js, Laravel, and Django schemas you can pan and zoom before you connect your own database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you should care about "stays current"
&lt;/h2&gt;

&lt;p&gt;Every method above the last one shares the same failure mode: the diagram is a snapshot, and keeping it current is your problem. Walk into any team that's been around a while and you'll find a "schema diagram" in a wiki from eight months ago. A wrong diagram is worse than none. It's confidently wrong.&lt;/p&gt;

&lt;p&gt;The test that settles it: &lt;strong&gt;how much work does it take to make this picture true again?&lt;/strong&gt; If the answer is "re-export from my IDE" or "drag the boxes by hand one more time," the humans will stop doing it and the diagram will lie to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What's the easiest way to generate an ER diagram from a Postgres database?&lt;/strong&gt;&lt;br&gt;
If you have a GUI client open, it's a built-in feature. pgAdmin's &lt;em&gt;ERD For Database&lt;/em&gt;, DBeaver's &lt;em&gt;ER Diagram&lt;/em&gt; tab, or DataGrip's visualization all generate one in a couple of clicks. For a shareable diagram that matches your database &lt;em&gt;as it is right now&lt;/em&gt;, a live-introspection tool is fastest because there's nothing to maintain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I create a Postgres ERD without connecting to the live database?&lt;/strong&gt;&lt;br&gt;
Yes. Run &lt;code&gt;pg_dump --schema-only&lt;/code&gt; and feed the DDL to a SQL-to-diagram tool, or parse your migration files. That's the safer path when you can't or don't want to expose production credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I keep a schema diagram up to date?&lt;/strong&gt;&lt;br&gt;
Manual tools require manual regeneration, so in practice they drift. The reliable fix is introspection: regenerate straight from the live database (a paste of the connection string) or wire documentation generation into CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does pgAdmin make ER diagrams?&lt;/strong&gt;&lt;br&gt;
Yes. pgAdmin 4 includes an ERD tool. Right-click the database → &lt;em&gt;ERD For Database&lt;/em&gt;, or &lt;em&gt;Tools → ERD Tool&lt;/em&gt;. Free and built in, though the auto-layout is best on small-to-medium schemas.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Just want a quick look? &lt;code&gt;psql&lt;/code&gt; or your IDE's built-in diagram. Fast, local, gone tomorrow, which is fine.&lt;/li&gt;
&lt;li&gt;Designing a new schema? draw.io, dbdiagram.io, or DrawSQL are the right tools for the job.&lt;/li&gt;
&lt;li&gt;Need to &lt;em&gt;understand a database that already exists&lt;/em&gt;, or document one that keeps changing? Introspect the live schema. That's the perspective that actually survives contact with real databases.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;Live: &lt;a href="https://dbdiagramr.space" rel="noopener noreferrer"&gt;https://dbdiagramr.space&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/VarunKvK/dbdiagramr" rel="noopener noreferrer"&gt;https://github.com/VarunKvK/dbdiagramr&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If this is useful to you, a GitHub star helps a solo dev keep building in public. I got tired of hand-drawing diagrams that went stale. Figured others did too.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>tutorial</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Swapped My Hand-Rolled SVG ER Diagram for React Flow + dagre . Here's What It Cost and What It Won</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Thu, 13 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/i-swapped-my-hand-rolled-svg-er-diagram-for-react-flow-dagre-heres-what-it-cost-and-what-it-won-56eg</link>
      <guid>https://dev.to/not_varunkv/i-swapped-my-hand-rolled-svg-er-diagram-for-react-flow-dagre-heres-what-it-cost-and-what-it-won-56eg</guid>
      <description>&lt;h2&gt;
  
  
  Where the story starts
&lt;/h2&gt;

&lt;p&gt;I built dbdiagramr paste a PostgreSQL connection string, get an interactive ER diagram in under 10 seconds. The first version rendered everything as &lt;strong&gt;pure SVG&lt;/strong&gt; I generated by hand. No canvas, no diagram library. That was a deliberate choice, and it worked.&lt;/p&gt;

&lt;p&gt;But as I pushed it from "a demo" to "a product" with a schema library and homepage previews, the hand-rolled renderer started fighting me. So I swapped the rendering engine for &lt;strong&gt;React Flow v12 + dagre&lt;/strong&gt;. This is that migration what broke, what I chose, and what the trade-offs actually look like in production.&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%2Fps9gvycc9c24h5wia9qd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fps9gvycc9c24h5wia9qd.png" alt="Db-diagramr result after using the library" width="800" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Interactive diagrams on the &lt;strong&gt;homepage demo&lt;/strong&gt; and every &lt;code&gt;/schema/[slug]&lt;/code&gt; detail page&lt;/li&gt;
&lt;li&gt;Hover a table → unrelated tables dim to 15% opacity so you can trace foreign-key relationships&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;same&lt;/strong&gt; diagram engine for static (SSG) pages and the client-side interactive canvas&lt;/li&gt;
&lt;li&gt;PNG + SVG export that matches what's on screen&lt;/li&gt;
&lt;li&gt;Keep pages fully static-server-rendered with a client hydration layer on top&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the first version did (and where it strained)
&lt;/h2&gt;

&lt;p&gt;The MVP rendered a static grid with straight-line connectors. It was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exportable for free&lt;/strong&gt; - SVG &lt;em&gt;is&lt;/em&gt; the document, serialize and you're done&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies&lt;/strong&gt; - one pure function, &lt;code&gt;schema in → SVG string out&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easy to reason about&lt;/strong&gt; - four TypeScript types underpin the whole app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first pain was &lt;strong&gt;foreign-key routing&lt;/strong&gt;. A clean diagram won't draw a line from table A to table B if it slices through three unrelated tables. My first pass was a four-way conditional that picked which edge to exit from based on relative position (above/below/left/right). It worked for small schemas and got fiddly as schemas grew. I already knew I'd revisit it.&lt;/p&gt;

&lt;p&gt;The real push, though, was &lt;strong&gt;interactivity and maintainability&lt;/strong&gt;. To get hover-to-trace, drag, pan, zoom, and the relationship tracing, I was going to bolt a lot of custom logic onto raw SVG. React Flow already solves that nodes, edges, viewport, minimap, controls and it's MIT licensed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why dagre (and not the obvious alternative)
&lt;/h2&gt;

&lt;p&gt;When you need a layout engine inside a diagram tool, the first name everyone mentions is ELK (via &lt;code&gt;elkjs&lt;/code&gt;). I seriously considered it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Great hierarchical layouts out of the box&lt;/li&gt;
&lt;li&gt;Nice output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But there's one hard blocker for dbdiagramr: &lt;strong&gt;it's MIT-only&lt;/strong&gt;. ELK is licensed under &lt;strong&gt;EPL-2.0&lt;/strong&gt; (and GPL-3.0 in places). Mixing that into an MIT product means taking on a license I don't want to reason about for a small open-source tool.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;dagre&lt;/strong&gt;. It's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MIT&lt;/strong&gt; - same license as the project&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synchronous&lt;/strong&gt; - runs in static-generation with zero async plumbing (all four schema pages are built at build time, so dagre's sync API keeps things simple)&lt;/li&gt;
&lt;li&gt;Ships its own TypeScript types - no &lt;code&gt;@types/dagre&lt;/code&gt; needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That combination was worth far more than a slightly better layout. The constraint turned an easy pick into the right pick.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;dagre&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@dagrejs/dagre&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;layoutSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Rect&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;dagre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;graphlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Graph&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setGraph&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;nodesep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ranksep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rankdir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// add each table node, sized from its columns&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tableSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// foreign keys → edges&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;foreignKeys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEdge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referencesTable&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;dagre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Rect&lt;/span&gt;&lt;span class="o"&gt;&amp;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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One renderer, two outputs
&lt;/h2&gt;

&lt;p&gt;The key architectural move was keeping a &lt;strong&gt;single layout source of truth&lt;/strong&gt; and letting two layers read from it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;renderDiagramSVG(schema, layout)&lt;/code&gt;&lt;/strong&gt; - produces the static SVG that SSG pages and the PNG export use&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;SchemaDiagram schema={...} /&amp;gt;&lt;/code&gt;&lt;/strong&gt; - a React Flow canvas that consumes the &lt;em&gt;same&lt;/em&gt; dagre layout, so the interactive diagram matches the static one pixel-for-pixel&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That one function is what guarantees the homepage, the schema library thumbnails, and the detail-page interactive canvas all agree. No drift between "what you see" and "what you export".&lt;/p&gt;

&lt;h2&gt;
  
  
  The interactive layer
&lt;/h2&gt;

&lt;p&gt;On top of dagre's layout, the React Flow layer gives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hover-to-trace&lt;/strong&gt; - on hover, unrelated tables drop to &lt;strong&gt;15% opacity&lt;/strong&gt; (related stay at 100%); edges animate so you can follow the foreign key through the diagram&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom nodes&lt;/strong&gt; - a &lt;code&gt;TableNode&lt;/code&gt; with &lt;strong&gt;row-anchored handles&lt;/strong&gt;: FK columns get a source handle on the right, referenced columns a target handle on the left, with PK/FK badges&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cardinality on edges&lt;/strong&gt; - &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;*&lt;/code&gt; badges rendered via an edge label layer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Viewport extras&lt;/strong&gt; - background zoom, minimap, and a &lt;strong&gt;download panel&lt;/strong&gt; exporting SVG or PNG (PNG is rasterized at &lt;strong&gt;2×&lt;/strong&gt; for crisp export)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of it sits on dagre's layout &lt;code&gt;rankdir: LR&lt;/code&gt;, 60px node spacing, 90px rank separation so related tables end up grouped left-to-right instead of scattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real schema looks like when you do it
&lt;/h2&gt;

&lt;p&gt;The site ships a &lt;strong&gt;free schema library&lt;/strong&gt; rendered this way real public schemas introspected into the exact &lt;code&gt;Schema&lt;/code&gt; type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Supabase auth - 7 tables, 3 foreign keys&lt;/li&gt;
&lt;li&gt;NextAuth.js / Auth.js - 4 tables, 2 relationships&lt;/li&gt;
&lt;li&gt;Laravel 11 default - 7 tables&lt;/li&gt;
&lt;li&gt;Django auth - 9 tables, 9 relationships&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The big one, Django (9 tables, 9 relationships), is nearly a star topology most tables reference the auth user table. dagre handles it fine; the lesson is to let a real layout engine deal with that instead of my hand-rolled routing conditional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honestly, what did it cost?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A static model is simpler.&lt;/strong&gt; The pure-SVG version truly was dependency-free. React Flow pulls in more code for edge routing, viewports, and type plumbing. That's a real trade, and I don't regret making it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;License is a design constraint.&lt;/strong&gt; The MIT-only requirement is what made dagre the right call over ELK and it was worth reasoning at the outset rather than mid-refactor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But what it bought:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interactive relationship tracing&lt;/strong&gt; the pure-SVG version never had the kind of thing I'd have spent weeks hand-building on raw SVG&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One layout, two outputs&lt;/strong&gt; an identical static renderer and canvas that never drift&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export that always matches the screen&lt;/strong&gt; SVG or 2× PNG, straight from the live view&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limitations (still)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL only - no MySQL, SQLite, or MongoDB yet&lt;/li&gt;
&lt;li&gt;Public schema only - custom schemas aren't supported yet&lt;/li&gt;
&lt;li&gt;Real-time sync is still not a thing - you generate, you export, you're done&lt;/li&gt;
&lt;li&gt;dagre handles the schema sizes here fine; at hundreds of tables I'd look at force layouts (and re-graph sync)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you're drawing relationships, don't hand-roll the canvas/network code when a purpose-built, MIT-licensed library exists. Let the library own the viewport and interaction; you keep the part that matters a &lt;strong&gt;small, typed data model&lt;/strong&gt; and a single renderer that powers both static and interactive output. My first instinct was "I can just draw this myself." It took a week of wiring to realize the library was doing me a favor the payoff is interactivity I'd have hand-built for far longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 14 (App Router)&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Tailwind CSS&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg&lt;/code&gt; for PostgreSQL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@xyflow/react&lt;/code&gt; (React Flow v12) for the canvas&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@dagrejs/dagre&lt;/code&gt; for layout (MIT)&lt;/li&gt;
&lt;li&gt;MIT licensed, fully self-hostable&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>opensource</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I Built a PostgreSQL Schema Visualizer With Pure TypeScript SVG - No Diagramming Library</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Sat, 01 Aug 2026 18:04:13 +0000</pubDate>
      <link>https://dev.to/not_varunkv/i-built-a-postgresql-schema-visualizer-with-pure-typescript-svg-no-diagramming-library-7ha</link>
      <guid>https://dev.to/not_varunkv/i-built-a-postgresql-schema-visualizer-with-pure-typescript-svg-no-diagramming-library-7ha</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I manage a few side projects with PostgreSQL databases. Every time I add a table, change a column, or refactor a relationship, I have to update my ER diagram.&lt;/p&gt;

&lt;p&gt;I was using draw.io. It works. But it's manual. And it's tedious.&lt;/p&gt;

&lt;p&gt;Open the file. Drag a new box. Connect the lines. Repeat for every schema change. It takes 30 minutes to an hour, it gets stale the moment someone commits a migration, and then you're back to answering "wait, what does the schema actually look like?"&lt;/p&gt;

&lt;p&gt;A coworker asks you that question. You open pgAdmin. You click through tables. You mentally assemble the picture. It's slow and it's only in your head.&lt;/p&gt;

&lt;p&gt;There had to be a better way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;dbdiagramr.&lt;/p&gt;

&lt;p&gt;Paste your PostgreSQL connection string. Get an interactive ER diagram in under 10 seconds. Pan, zoom, hover over tables to trace relationships, drag tables around. Export as SVG or PNG.&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%2F7w5qlw0bkqnwl1jgqtuz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7w5qlw0bkqnwl1jgqtuz.png" alt="Er-diagram creator named dbdiagram" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;The whole flow is four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You paste your connection string&lt;/li&gt;
&lt;li&gt;A server API introspects &lt;code&gt;information_schema&lt;/code&gt; for tables, columns, keys&lt;/li&gt;
&lt;li&gt;A pure TypeScript function generates an SVG diagram&lt;/li&gt;
&lt;li&gt;You pan, zoom, and explore&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No Canvas. No heavy libraries. Just SVG.&lt;/p&gt;

&lt;p&gt;The data model is deliberately small - four types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Column&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;isPrimaryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ForeignKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;referencesTable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;referencesColumn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;foreignKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Table&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;Every diagram is just a &lt;code&gt;Schema&lt;/code&gt; object. Everything else is rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Introspection
&lt;/h2&gt;

&lt;p&gt;The server connects to your database and reads only the &lt;em&gt;structure&lt;/em&gt; — never your data. For each table it runs three queries in parallel against &lt;code&gt;information_schema&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="c1"&gt;-- Columns&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_nullable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column_default&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ordinal_position&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Primary keys&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_constraints&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_column_usage&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PRIMARY KEY'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Foreign keys&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ccu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;foreign_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ccu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;foreign_column_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_constraints&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_column_usage&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_column_usage&lt;/span&gt; &lt;span class="n"&gt;ccu&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ccu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'FOREIGN KEY'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&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;These run server-side in a single API route, get assembled into a typed &lt;code&gt;Schema&lt;/code&gt; object, and get sent back as JSON. The client never touches the database directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SVG (and not Canvas)
&lt;/h2&gt;

&lt;p&gt;I chose pure SVG for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Accessible by default&lt;/strong&gt; - text is text, colors are colors. No rasterization needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exportable for free&lt;/strong&gt; - SVG &lt;em&gt;is&lt;/em&gt; the document. Serialize it and you're done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No dependencies&lt;/strong&gt; - the diagram generator is a pure function: &lt;code&gt;schema&lt;/code&gt; in, SVG string out.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateDiagramSVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// grid layout&lt;/span&gt;
  &lt;span class="c1"&gt;// table cards → &amp;lt;g&amp;gt; elements&lt;/span&gt;
  &lt;span class="c1"&gt;// foreign keys → &amp;lt;line&amp;gt; with arrow markers&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single function produces the whole diagram server-side or client-side. The interactive React component sits on top and layers on pan, zoom, drag, and hover highlighting roughly 400 lines.&lt;/p&gt;

&lt;p&gt;For PNG export, I serialize the current SVG (with tight viewBox and inlined styles), draw it to a canvas, and let the browser encode it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;svgString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;serializeSvg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svgElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;positions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;svgString&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/svg+xml;charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// draw to canvas, canvas.toBlob → PNG download&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Hard Part: Foreign Key Routing
&lt;/h2&gt;

&lt;p&gt;The most interesting problem wasn't drawing the boxes - it was deciding &lt;em&gt;where the connection lines exit each table&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A foreign key connects column A in table 1 to column B in table 2. But which edge of table 1 should the line leave from? If table 1 is above table 2, the line exits the bottom. If they're side by side, it exits the right side. If one is much taller, it gets complicated.&lt;/p&gt;

&lt;p&gt;The naive version produced lines that crossed through unrelated tables and made the diagram unreadable.&lt;/p&gt;

&lt;p&gt;The fix was a four-way conditional that decides based on relative position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// source sits above target → line exits bottom, enters top&lt;/span&gt;
  &lt;span class="nx"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;x2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tCx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tTop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// source sits below target → line exits top, enters bottom&lt;/span&gt;
  &lt;span class="nx"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;x2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tCx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// source sits left of target → line exits right edge&lt;/span&gt;
  &lt;span class="nx"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;x2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tLeft&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tCy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// source sits right of target → line exits left edge&lt;/span&gt;
  &lt;span class="nx"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;x2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tRight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tCy&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;Simple on paper. It still took a while to get right because the cases interact - and the fallback (overlapping boxes) matters more than you'd think.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A tiny data model wins.&lt;/strong&gt; Four types. The whole app is built on them, and it keeps the mental overhead near zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The grid layout breaks at - 50 tables.&lt;/strong&gt; A static grid is fine for most schemas but gets cluttered on big ones. Next step would be a force-directed layout or at least smarter clustering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipping beats perfecting.&lt;/strong&gt; I could have kept polishing the routing algorithm forever. Shipping it and getting real feedback taught me more than any extra week of tweaking.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest Limitations
&lt;/h2&gt;

&lt;p&gt;I'll say the same thing I'd tell a friend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL only. No MySQL, SQLite, or MongoDB yet.&lt;/li&gt;
&lt;li&gt;Public schema only - custom schemas aren't supported yet.&lt;/li&gt;
&lt;li&gt;No real-time sync. You generate, you export, you're done.&lt;/li&gt;
&lt;li&gt;Static grid layout struggles past ~50 tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 14 (App Router)&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Tailwind CSS&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg&lt;/code&gt; for PostgreSQL&lt;/li&gt;
&lt;li&gt;Pure SVG for diagrams&lt;/li&gt;
&lt;li&gt;MIT licensed, fully self-hostable&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>showdev</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The contract clause that almost cost me my entire codebase</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Sat, 06 Jun 2026 02:30:00 +0000</pubDate>
      <link>https://dev.to/not_varunkv/the-contract-clause-that-almost-cost-me-my-entire-codebase-j9d</link>
      <guid>https://dev.to/not_varunkv/the-contract-clause-that-almost-cost-me-my-entire-codebase-j9d</guid>
      <description>&lt;p&gt;&lt;strong&gt;I almost signed it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The project looked good. The client seemed reasonable. The money was fair. I'd been freelancing long enough to know the drill get the contract, skim it, sign it, start working.&lt;/p&gt;

&lt;p&gt;This time, for no particular reason, I actually read it.&lt;br&gt;
Buried in section 4 or maybe section 6, I don't remember exactly was a line that stopped me cold. Something about "pre-existing materials." Standard boilerplate, right? Except it wasn't.&lt;br&gt;
The clause said that anything I brought into the project including tools, libraries, and code I'd written before the engagement would become the exclusive property of the client upon completion.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Let that sink in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every utility function I'd spent years refining. Every internal library I reused across client projects. Every tool I'd built for my own workflow. All of it. Theirs. The moment I delivered the project.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "pre-existing materials" actually means
&lt;/h2&gt;

&lt;p&gt;Most freelance contracts have an IP assignment clause. That's normal the client pays you to build something, they own what you built. Fair enough.&lt;/p&gt;

&lt;p&gt;But "pre-existing materials" is different. It's the stuff you owned before you ever heard of this client. Your code. Your tools. Your background IP.&lt;/p&gt;

&lt;p&gt;A well-written contract carves this out explicitly. Something like: "Contractor retains ownership of all pre-existing materials. Client receives a license to use them as incorporated into the deliverables."&lt;/p&gt;

&lt;p&gt;A badly written or deliberately predatory contract does the opposite. It lumps everything together under one broad assignment clause and hopes you don't notice.&lt;br&gt;
I noticed.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I did
&lt;/h2&gt;

&lt;p&gt;I pushed back. Told the client the clause needed to be amended before I could sign.&lt;/p&gt;

&lt;p&gt;The response surprised me. They got defensive. Not "oh that's just boilerplate, of course we'll fix it" defensive. Like I'd caught them doing something they knew was wrong.&lt;/p&gt;

&lt;p&gt;That told me everything I needed to know. I walked away from the project.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to look for in any freelance contract
&lt;/h2&gt;

&lt;p&gt;If you take nothing else from this, scan every contract you sign for these:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;1. Pre-existing materials / background IP&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Any clause that assigns ownership of things you brought to the project, not just what you built for it. Red flag if there's no &lt;br&gt;
explicit carve-out retaining your ownership.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;2. Perpetual non-competes&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"During and after the term of this agreement" with no time limit. This means forever. A reasonable non-compete has a duration — 6 or 12 months. No duration means no end.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;3. Unlimited liability&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If something goes wrong — a bug, a security issue, anything — are you personally on the hook with no cap? Some contracts make you liable for damages that could exceed what you were even paid.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;4. Payment at sole discretion&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Client may withhold payment for deliverables deemed unsatisfactory at Client's sole discretion." Translation: they can refuse to pay for any reason and you have no recourse.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;5. Work for hire with no exceptions&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everything you create during the engagement becomes theirs. Fine for the deliverables. Not fine if it includes general tools, open source contributions, or personal projects you worked on simultaneously.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a safe version looks like
&lt;/h2&gt;

&lt;p&gt;Here's the rewrite I proposed for the pre-existing materials clause:&lt;/p&gt;

&lt;p&gt;"Contractor retains all ownership of pre-existing materials, tools, and background technology used in the project. Client receives a perpetual, non-exclusive, royalty-free license to use such materials solely as incorporated into the deliverables. All intellectual property developed specifically for this project shall be owned by Client upon receipt of full payment."&lt;/p&gt;

&lt;p&gt;That's it. Both parties are protected. Client gets what they paid for. You keep what you built before they ever hired you.&lt;br&gt;
Most reasonable clients will accept this without argument. The ones who don't are telling you something important about how the rest of the engagement will go.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I built Clause
&lt;/h2&gt;

&lt;p&gt;After this happened I started paying more attention to contracts. Talked to other freelancers. Turns out this isn't rare it's common. &lt;/p&gt;

&lt;p&gt;Clients use broad boilerplate and count on developers being too busy, too eager, or too intimidated to read it carefully.&lt;/p&gt;

&lt;p&gt;So I built a tool that does the reading for you.&lt;br&gt;
Paste any contract into Clause and it flags the dangerous clauses explains what each one means in plain English, why it's risky, and suggests a fairer rewrite you can send back to the client.&lt;br&gt;
It's free to use and doesn't require an account to try.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.clauseapp.space/" rel="noopener noreferrer"&gt;Clauseapp.space&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've ever been burned by a contract clause or nearly missed on I'd genuinely like to hear about it in the comments.&lt;/p&gt;

</description>
      <category>freelancing</category>
      <category>webdev</category>
      <category>career</category>
      <category>discuss</category>
    </item>
    <item>
      <title>How to Create Product GIFs for Your Landing Page (No Video Editing Required)</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Fri, 02 Jan 2026 18:24:08 +0000</pubDate>
      <link>https://dev.to/not_varunkv/how-to-create-product-gifs-for-your-landing-page-no-video-editing-required-5b89</link>
      <guid>https://dev.to/not_varunkv/how-to-create-product-gifs-for-your-landing-page-no-video-editing-required-5b89</guid>
      <description>&lt;p&gt;&lt;strong&gt;The Brochure vs. The Demo&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Imagine walking into an electronics store.&lt;br&gt;
You see a sleek new laptop.&lt;br&gt;
Do you just stare at the turned-off screen? No. You touch the trackpad. You open a window. You want to see it work.&lt;/p&gt;

&lt;p&gt;Your landing page is that store.&lt;br&gt;
But most SaaS landing pages are just... brochures.&lt;/p&gt;

&lt;p&gt;They have beautiful, high-res, completely static screenshots. They force the user to read about the features instead of seeing the features.&lt;/p&gt;

&lt;p&gt;And reading is hard work.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The 3-Second Rule&lt;/em&gt;&lt;br&gt;
You have roughly 3 seconds to convince a visitor to stay on your site.&lt;/p&gt;

&lt;p&gt;Static Image: The brain has to process the text, look at the UI, map the text to the UI, and simulate the interaction mentally. Cognitive load: High.&lt;br&gt;
GIF/Video: The brain sees movement. It instantly understands "Clicking X does Y." Cognitive load: Low.&lt;br&gt;
Motion is a cheat code for attention.&lt;/p&gt;

&lt;p&gt;Why Not Just Use Video?&lt;br&gt;
"Okay," you say. "I'll just embed a YouTube video."&lt;/p&gt;

&lt;p&gt;You could. But videos are heavy. They require a click to play. They have sound (risky). They take over the experience.&lt;/p&gt;

&lt;p&gt;GIFs are the sweet spot.&lt;/p&gt;

&lt;p&gt;Autoplay: No click needed.&lt;br&gt;
Silent: Safe for work.&lt;br&gt;
Looping: Reinforces the core value proposition.&lt;br&gt;
Lightweight: Loads faster than a 4K video embed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Problem with GIF Creation&lt;/strong&gt;&lt;br&gt;
Creating a polished product GIF used to be painful.&lt;/p&gt;

&lt;p&gt;Record screen (OBS/Loom).&lt;br&gt;
Import to Premiere/After Effects.&lt;br&gt;
Add a device frame overlay.&lt;br&gt;
Mask the video to fit the screen.&lt;br&gt;
Add a background.&lt;br&gt;
Export and pray the file size isn't 50MB.&lt;br&gt;
Most developers (and even designers) don't have time for this. So they settle for static screenshots.&lt;/p&gt;

&lt;p&gt;The Shotframe Solution: GIFs in the Browser&lt;br&gt;
We added GIF support to Shotframe to solve exactly this problem. We wanted the polish of After Effects with the speed of a screenshot tool.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;How it works:&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Record Your Screen: Use any tool to grab a short clip of your key feature. (Keep it under 5 seconds for best results).&lt;br&gt;
Drop into Shotframe: Upload your video file just like an image.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgolscyr5khbg357gzi0a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgolscyr5khbg357gzi0a.png" alt="Drag and Drop the image on Shotframe" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Frame It: Choose a premium device frame (iPhone, MacBook, Browser). Shotframe handles the masking automatically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnvh7jfhyyjgnrxspsc0i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnvh7jfhyyjgnrxspsc0i.png" alt="Selected Iphone as a mockup" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Style It: Add a custom gradient background (use BlendIt to match your brand colors!).&lt;br&gt;
Export: Download a highly optimized GIF ready for your landing page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffymzyl9ulgk2t9a8kfw4.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffymzyl9ulgk2t9a8kfw4.gif" alt="GIF in action" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best Practices for Product GIFs&lt;/strong&gt;&lt;br&gt;
If you're going to use GIFs, do it right:&lt;/p&gt;

&lt;p&gt;Keep it Short: 3-5 seconds max. Focus on ONE interaction.&lt;br&gt;
Start with Action: Don't have 2 seconds of a stillness at the start. Movement should happen immediately.&lt;br&gt;
High Contrast: Make sure the cursor and the button clicks are visible.&lt;br&gt;
Optimization: Don't upload a 20MB GIF to your hero section. Use Shotframe’s export settings to balance quality and size.&lt;br&gt;
Conclusion&lt;br&gt;
Your product is dynamic. Your marketing should be too.&lt;br&gt;
Stop letting your hard work sit still. Make it move.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.shotframe.space/" rel="noopener noreferrer"&gt;[Try the Shotframe GIF Maker →]&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tutorial</category>
      <category>design</category>
    </item>
    <item>
      <title>How to Make Product Mockups That Don't Look Like Templates</title>
      <dc:creator>Varun Krishnan</dc:creator>
      <pubDate>Thu, 01 Jan 2026 15:59:59 +0000</pubDate>
      <link>https://dev.to/not_varunkv/how-to-make-product-mockups-that-dont-look-like-templates-f78</link>
      <guid>https://dev.to/not_varunkv/how-to-make-product-mockups-that-dont-look-like-templates-f78</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Stop using generic mockup backgrounds. Learn how to create custom, brand-aligned mockups (and GIFs) directly from your browser. No Figma required.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj4aqhcluz3j10tzf559b.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj4aqhcluz3j10tzf559b.gif" alt=" " width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Problem With Modern Mockups&lt;/strong&gt;&lt;br&gt;
Scroll through Dribbble, Product Hunt, or any SaaS landing page. You’ll see the same thing:&lt;/p&gt;

&lt;p&gt;A generic MacBook or iPhone frame.&lt;br&gt;
A "mesh gradient" background (usually purple or blue).&lt;br&gt;
A shadow that looks slightly fake.&lt;br&gt;
It’s the "Startup Starter Pack." And because everyone uses the same templates, no one stands out.&lt;/p&gt;

&lt;p&gt;If you’ve spent weeks building a unique product, wrapping it in a generic mockup kills the vibe instantly.&lt;/p&gt;

&lt;p&gt;Your product deserves better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Context Matters (And Why Templates Fail)&lt;/strong&gt;&lt;br&gt;
A mockup's job isn't just to frame your screenshot. Its job is to extend your brand.&lt;/p&gt;

&lt;p&gt;When you use a random gradient template, you are clashing with your product's actual design.&lt;/p&gt;

&lt;p&gt;The Pro Move: Your background colors should be extracted from the product screenshot itself.&lt;br&gt;
The Result: Perfect color harmony. The background feels like an extension of the UI, not a wrapper.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Method 1: The "Figma Workflow" (The Hard Way)&lt;/em&gt;&lt;br&gt;
You can do this manually, of course.&lt;/p&gt;

&lt;p&gt;Take a screenshot.&lt;br&gt;
Open Figma.&lt;br&gt;
Import screenshot.&lt;br&gt;
Use the Eyedropper tool to pick 3-4 dominant colors.&lt;br&gt;
Create a rectangle layer behind the image.&lt;br&gt;
Apply a linear or radial gradient using those colors.&lt;br&gt;
Add a device frame (search community files for a mockup).&lt;br&gt;
Add drop shadows manually.&lt;br&gt;
Export.&lt;br&gt;
Time: ~15-20 minutes per image.&lt;br&gt;
Result: Good, but slow.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Method 2: The Shotframe Workflow (The Fast Way)&lt;/em&gt;&lt;br&gt;
We built Shotframe to automate the "Pro Move."&lt;/p&gt;

&lt;p&gt;Step 1: Upload Your Screenshot&lt;br&gt;
Drop your image into the browser. No login needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpyqdmmu31i7vew1zhkwh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpyqdmmu31i7vew1zhkwh.png" alt="Shotframe Drag and Drop Feature" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step 2: Auto-Match the Background&lt;br&gt;
Instead of picking a random color, use a tool like BlendIt to generate a gradient directly from your screenshot. Upload that gradient as your background in Shotframe.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9erfdx7vfopc4xoe91d2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9erfdx7vfopc4xoe91d2.png" alt="Generate gradient on BlendIt" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now your background matches your UI perfectly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh6lz7p19bnc5q66xffil.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh6lz7p19bnc5q66xffil.png" alt="Matched to the Image" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step 3: Choose Your Device&lt;br&gt;
Select from premium device frames (Safari Dark, iPhone 15, Chrome Light). It wraps your screenshot automatically with perfect shadows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuj6u88jzkuwtapp4wd81.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuj6u88jzkuwtapp4wd81.png" alt="Choose any type of device" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step 4: Go Beyond Static (New Feature)&lt;br&gt;
Static images are fine. Motion is better.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzq4z2z05a7mnmonpomil.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzq4z2z05a7mnmonpomil.png" alt="GIF feature on shotframe" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Shotframe now lets you export as GIF.&lt;/p&gt;

&lt;p&gt;Show a scrolling capture.&lt;br&gt;
Show a UI interaction.&lt;br&gt;
Show a workflow.&lt;br&gt;
This turns a "picture of an app" into a "demo of an app."&lt;/p&gt;

&lt;p&gt;Why GIFs Convert Better Than Images&lt;br&gt;
On a landing page, you have about 3 seconds to explain what your tool does.&lt;/p&gt;

&lt;p&gt;Screenshot: The user has to read the UI to understand it.&lt;br&gt;
GIF: The user sees the UI working.&lt;br&gt;
It builds trust. It proves the software is real. It catches the eye.&lt;/p&gt;

&lt;p&gt;Creating Your First Custom Mockup&lt;br&gt;
Go to Shotframe.xyz.&lt;br&gt;
Drop your image.&lt;br&gt;
Tweak the background (or upload your custom BlendIt gradient).&lt;br&gt;
Hit Export.&lt;br&gt;
Stop letting generic mockups lower your product's value. Make it look as premium as the code behind it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.shotframe.space/" rel="noopener noreferrer"&gt;[Create a Free Mockup Now →]&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>productivity</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
