<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Feroz Shah]]></title><description><![CDATA[Feroz Shah]]></description><link>https://fsmutimeer.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 31 Oct 2026 18:51:05 GMT</lastBuildDate><atom:link href="https://fsmutimeer.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Generated SQL migrations for Python, TypeScript and Go projects]]></title><description><![CDATA[Migrax started as a tool for Java: it reads JPA entities and writes the SQL migrations that bring the database in line with them. But the idea doesn't…]]></description><link>https://fsmutimeer.hashnode.dev/generated-sql-migrations-for-python-typescript-and-go-projects</link><guid isPermaLink="true">https://fsmutimeer.hashnode.dev/generated-sql-migrations-for-python-typescript-and-go-projects</guid><category><![CDATA[Python]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[golang]]></category><dc:creator><![CDATA[Feroz Shah]]></dc:creator><pubDate>Sat, 31 Oct 2026 12:05:03 GMT</pubDate><content:encoded><![CDATA[<p>Migrax started as a tool for Java: it reads JPA entities and writes the SQL migrations that bring the database in line with them. But the idea doesn't depend on Java. Every ORM knows the tables it expects, and almost every ORM can print them as <code>CREATE TABLE</code> statements.</p>
<p>That SQL is all Migrax needs. Keep it in a schema file, and Migrax compares it with what the existing migrations build, writes the next numbered migration with its rollback script, and applies it. This article follows a small Python project through its first changes, then shows the same loop with Drizzle in TypeScript and GORM in Go.</p>
<blockquote>
<p><strong>Tested with:</strong> Migrax 0.17.2-rc.1, PostgreSQL 17, SQLAlchemy 2.1.6 (Python 3.13), Drizzle ORM 0.45.4 with drizzle-kit 0.31.11 (Node.js 24), GORM 1.31.2 (Go 1.25). Docker runs the throwaway databases. Every output below was copied from a real run.</p>
</blockquote>
<h2>The idea in one picture</h2>
<pre><code>models.py  --export--&gt;  db/schema.sql  --migrax generate--&gt;  db/migrations/0002_....sql
 (your ORM)              (the tables                          + rollback/0002_....sql
                          you want)
</code></pre>
<p>The schema file says what the tables should look like. The migrations say how to get there, one reviewed step at a time. Both are committed. Migrax never touches your models, and your application never needs Migrax at runtime: the migrations are plain SQL files.</p>
<h2>A Python shop with SQLAlchemy</h2>
<p>Two tables, customers and their orders:</p>
<p><code>models.py</code>:</p>
<pre><code class="language-python">from datetime import datetime
from decimal import Decimal

from sqlalchemy import ForeignKey, Numeric, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Customer(Base):
    __tablename__ = "customer"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(150), unique=True)
    name: Mapped[str] = mapped_column(String(100))
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())


class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id"), index=True)
    total: Mapped[Decimal] = mapped_column(Numeric(12, 2))
    status: Mapped[str] = mapped_column(String(20), server_default="new")
</code></pre>
<p>SQLAlchemy compiles a table into PostgreSQL's <code>CREATE TABLE</code> without connecting to anything. A short script turns the models into the schema file:</p>
<p><code>export_schema.py</code>:</p>
<pre><code class="language-python">"""Prints the tables of models.py as SQL, for Migrax's schema file."""
from sqlalchemy.dialects import postgresql
from sqlalchemy.schema import CreateIndex, CreateTable

from models import Base

dialect = postgresql.dialect()
for table in Base.metadata.sorted_tables:
    print(f"{str(CreateTable(table).compile(dialect=dialect)).strip()};\n")
    for index in sorted(table.indexes, key=lambda i: i.name):
        print(f"{CreateIndex(index).compile(dialect=dialect)};\n")
</code></pre>
<p>Migrax reads its settings from the environment. The schema file is one more of them:</p>
<pre><code class="language-bash">export MIGRAX_DATABASE_URL=jdbc:postgresql://localhost:55432/shop
export MIGRAX_DATABASE_USER=shop
export MIGRAX_DATABASE_PASSWORD=secret
export MIGRAX_LOCATIONS=filesystem:db/migrations
export MIGRAX_SCHEMA_FILE=db/schema.sql
export MIGRAX_DRIVERS=drivers        # holds postgresql-42.7.14.jar
</code></pre>
<h3>The first migration</h3>
<pre><code class="language-shell">$ python export_schema.py &gt; db/schema.sql
$ migrax generate
Starting a throwaway postgresql database (postgres:17-alpine)...
Read db/schema.sql: 2 table(s).
No snapshot yet: using the current database schema as the baseline.
Created db/migrations/0001_initial.sql:
  - create table customer
  - create table orders
  - add index ix_orders_customer_id on orders
  - add foreign key orders_customer_id_fkey on orders
Rollback script written to db/migrations/rollback.
Review the SQL, then run 'migrax migrate'.
</code></pre>
<p>To read the file, Migrax runs it on a throwaway PostgreSQL in Docker, in the same version as your server, and reads the tables back. So whatever the database accepts, Migrax understands, and what it doesn't accept fails here, not in production. The migration it writes:</p>
<p><code>db/migrations/0001_initial.sql</code>:</p>
<pre><code class="language-sql">CREATE TABLE customer (id integer GENERATED BY DEFAULT AS IDENTITY NOT NULL, email varchar(150) NOT NULL, name varchar(100) NOT NULL, created_at timestamp(6) DEFAULT now() NOT NULL, CONSTRAINT customer_pkey PRIMARY KEY (id), CONSTRAINT uk_customer_email UNIQUE (email));
CREATE TABLE orders (id integer GENERATED BY DEFAULT AS IDENTITY NOT NULL, customer_id integer NOT NULL, total numeric(12,2) NOT NULL, status varchar(20) DEFAULT 'new' NOT NULL, CONSTRAINT orders_pkey PRIMARY KEY (id));
CREATE INDEX ix_orders_customer_id ON orders (customer_id);
ALTER TABLE orders ADD CONSTRAINT orders_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES customer (id);
</code></pre>
<p>Two details differ from SQLAlchemy's own SQL, and both are on purpose. SQLAlchemy writes <code>SERIAL</code>; Migrax writes identity columns, which hand out ids the same way. And constraints get names (<code>uk_customer_email</code>), so later migrations can refer to them.</p>
<pre><code class="language-shell">$ migrax migrate
Applying migration '0001_initial.sql'...
Successfully applied migration '0001_initial.sql'.
Applied 1 migration(s); 1 total in db/migrations.
</code></pre>
<p>The application saves its first customer, Ada Lovelace, with one order of 42.50.</p>
<h3>Adding a column</h3>
<p>A phone number for customers:</p>
<pre><code class="language-python">    phone: Mapped[str | None] = mapped_column(String(30))
</code></pre>
<p>Export again. Before writing anything, <code>migrax check</code> tells you what has no migration yet:</p>
<pre><code class="language-shell">$ python export_schema.py &gt; db/schema.sql
$ migrax check
Starting a throwaway postgresql database (postgres:17-alpine)...
Read db/schema.sql: 2 table(s).
1 change(s) in db/schema.sql have no migration:
  - add column customer.phone
Run 'migrax generate' and commit the result.
$ echo $?
2
</code></pre>
<p>Exit code 2 means "changes without a migration", which is the line to put in CI. Then:</p>
<pre><code class="language-shell">$ migrax generate
Created db/migrations/0002_add_customer_phone.sql:
  - add column customer.phone
Rollback script written to db/migrations/rollback.
Review the SQL, then run 'migrax migrate'.
</code></pre>
<p>No database started this time: Migrax had read this version of the file a moment earlier and cached what it found in <code>.migrax/schema-model.json</code>. The migration and its rollback script:</p>
<p><code>db/migrations/0002_add_customer_phone.sql</code>:</p>
<pre><code class="language-sql">ALTER TABLE customer ADD COLUMN phone varchar(30);
</code></pre>
<p><code>db/migrations/rollback/0002_add_customer_phone.sql</code>:</p>
<pre><code class="language-sql">-- Rollback for 0002_add_customer_phone.sql. 'migrax rollback' runs it.
-- Note: deletes data written to customer.phone since the migration ran.

ALTER TABLE customer DROP COLUMN phone;
</code></pre>
<h3>Renaming a column, keeping the data</h3>
<p><code>name</code> becomes <code>full_name</code>. Compared naively, that is a dropped column and a new one, and Ada's name would be gone. Migrax notices the likely rename. In a terminal it asks; in a script, where nobody can answer, it stops before dropping anything:</p>
<pre><code class="language-shell">$ migrax generate
Possible rename: customer.name to customer.full_name. To keep the data, rerun with --rename customer.name=full_name.
2 change(s) for postgresql:
  1. add column customer.full_name
     ALTER TABLE customer ADD COLUMN full_name varchar(100) NOT NULL;
  2. drop column customer.name  [DESTRUCTIVE]
     ALTER TABLE customer DROP COLUMN name;
error[MXE108]: 1 destructive change(s) detected (marked [DESTRUCTIVE] above).
Review them, then run 'migrax generate --allow-destructive'. If a column or table was renamed, pass --rename table.old=new or --rename-table old=new instead.
$ migrax generate --rename customer.name=full_name
Created db/migrations/0003_rename_customer_name.sql:
  - rename column customer.name to full_name
Rollback script written to db/migrations/rollback.
db/migrations/0003_rename_customer_name.sql, statement 1: warning MX008
  ALTER TABLE customer RENAME COLUMN name TO full_name
  Application instances still running the old code use the old name and will fail.
  Fix: Rename in steps: add the new name, write to both, switch reads, then remove the old name ('migrax generate --zero-downtime' does this). Or deploy during a maintenance window.
Review the SQL, then run 'migrax migrate'.
</code></pre>
<p><code>db/migrations/0003_rename_customer_name.sql</code>:</p>
<pre><code class="language-sql">ALTER TABLE customer RENAME COLUMN name TO full_name;
</code></pre>
<p>The warning is about deployment, not data: old instances that still query <code>name</code> break when the column is renamed. After <code>migrax migrate</code>, a three-line script that lists the customers through SQLAlchemy reads the row back under its new name:</p>
<pre><code class="language-shell">$ python read.py
1 ada@example.com Ada Lovelace
</code></pre>
<h3>Proving the migrations</h3>
<p>The migrations now describe the schema in three steps. <code>migrax verify</code> checks that they really build what the schema file says, on an empty throwaway database of your server's version, and that every rollback script works:</p>
<pre><code class="language-shell">$ migrax verify
Starting a throwaway postgresql database (postgres:17-alpine)...
Applying migration '0001_initial.sql'...
...
Applied 3 migration(s) to a fresh postgresql database.
Schema matches the schema file (structural comparison).
Rolling back migration '0003_rename_customer_name.sql'...
...
Rollback scripts: ok (3 rolled back and re-applied).
Verified.
</code></pre>
<p>And <code>migrax status</code> shows where the real database is:</p>
<pre><code class="language-shell">$ migrax status
Migrations in db/migrations for jdbc:postgresql://localhost:55432/shop
  [X] 0001_initial.sql               applied 2026-10-26 16:02:43
  [X] 0002_add_customer_phone.sql    applied 2026-10-26 16:02:51
  [X] 0003_rename_customer_name.sql  applied 2026-10-26 16:02:58
3 applied.
</code></pre>
<p>Undoing the rename takes one command. In a terminal, <code>rollback</code> asks before it changes anything; in a script, pass <code>--yes</code>:</p>
<pre><code class="language-shell">$ migrax rollback --yes
Rolling back 1 migration(s), newest first:
  0003_rename_customer_name.sql
Rolling back migration '0003_rename_customer_name.sql'...
Rolled back migration '0003_rename_customer_name.sql'.
Rolled back 1 migration(s).
</code></pre>
<p>The column is called <code>name</code> again, with Ada's name still in it. <code>migrax migrate</code> applies the rename once more.</p>
<h2>The same loop in TypeScript, with Drizzle</h2>
<p><code>drizzle-kit export</code> prints a Drizzle schema as SQL, without a database:</p>
<pre><code class="language-shell">$ npx drizzle-kit export --dialect=postgresql --schema=./schema.ts &gt; db/schema.sql
$ migrax generate
Starting a throwaway postgresql database (postgres:17-alpine)...
Read db/schema.sql: 2 table(s).
No snapshot yet: using the current database schema as the baseline.
Created db/migrations/0001_initial.sql:
  - create table customer
  - create table orders
  - add index orders_customer on orders
  - add foreign key orders_customer_id_customer_id_fk on orders
Rollback script written to db/migrations/rollback.
Review the SQL, then run 'migrax migrate'.
</code></pre>
<p>Adding <code>phone: varchar("phone", { length: 30 })</code> to the <code>customer</code> table and exporting again gives <code>0002_add_customer_phone.sql</code> with the same single <code>ALTER TABLE customer ADD COLUMN</code> as in Python. Drizzle's own names for indexes and foreign keys are kept as they are.</p>
<h2>The same loop in Go, with GORM</h2>
<p>GORM can't print its models as SQL, but its <code>AutoMigrate</code> creates them. Let it create the tables in a database of their own (<code>shop_dev</code>, never the one Migrax migrates), and copy them from there with <code>pg_dump</code>:</p>
<p><code>models.go</code>:</p>
<pre><code class="language-go">type Customer struct {
	ID        uint      `gorm:"primaryKey"`
	Email     string    `gorm:"size:150;not null;uniqueIndex"`
	Name      string    `gorm:"size:100;not null"`
	CreatedAt time.Time `gorm:"not null;default:now()"`
}

type Order struct {
	ID         uint    `gorm:"primaryKey"`
	CustomerID uint    `gorm:"not null;index"`
	Customer   Customer
	Total      float64 `gorm:"type:numeric(12,2);not null"`
	Status     string  `gorm:"size:20;not null;default:'new'"`
}
</code></pre>
<pre><code class="language-shell">$ go run .            # db.AutoMigrate(&amp;Customer{}, &amp;Order{}) on shop_dev
$ pg_dump --schema-only --no-owner --no-privileges shop_dev &gt; db/schema.sql
$ migrax generate
Starting a throwaway postgresql database (postgres:17-alpine)...
Read db/schema.sql: 2 table(s).
No snapshot yet: using the current database schema as the baseline.
Created db/migrations/0001_initial.sql:
  - create table customers
  - create table orders
  - add index idx_orders_customer_id on orders
  - add foreign key fk_orders_customer on orders
Rollback script written to db/migrations/rollback.
Review the SQL, then run 'migrax migrate'.
</code></pre>
<p>Why not let <code>AutoMigrate</code> change the real database directly? It is fine in development, and GORM is upfront about its limits: its <a href="https://gorm.io/docs/migration.html">migration docs</a> (as of October 2026) say it "WON'T delete unused columns to protect your data" and that "at some point you may need to switch to a versioned migrations strategy". With the dump in between, <code>AutoMigrate</code> stays where it is convenient, and production gets reviewed, numbered migrations with rollback scripts, renames that keep their data, and drops that need your confirmation.</p>
<h2>In CI</h2>
<p>Two lines catch a model change that has no migration:</p>
<pre><code class="language-bash">python export_schema.py &gt; db/schema.sql   # or: npx drizzle-kit export ..., pg_dump ...
migrax check                              # exit code 2: a change has no migration yet
</code></pre>
<p>Commit <code>db/schema.sql</code> and the <code>.migrax/</code> folder with every migration. While the schema file is unchanged, <code>migrax check</code> reads the saved model and needs no Docker at all. Migrax itself runs on Java 17 or newer; a Python, Node or Go build that has no Java can use the container image <code>ghcr.io/fsmutimeer/migrax</code> instead.</p>
<h2>What to know</h2>
<ul>
<li><strong>Read the migration before you commit it.</strong> It is plain SQL, generated, but yours.</li>
<li><strong>Views, functions, triggers and sequences</strong> don't belong in the schema file. Keep them in repeatable migrations (<code>R__*.sql</code>); identity columns work in the file.</li>
<li><strong>Reshaping data is yours to write.</strong> Migrax moves the structure and keeps values through renames. Splitting <code>full_name</code> into first and last name needs a rule only you know: a migration written with <code>migrax new</code>, between two generated ones.</li>
<li><strong>MySQL and MariaDB work the same way.</strong> Compile with SQLAlchemy's <code>mysql</code> dialect, or dump with <code>mysqldump --no-data --skip-comments</code>.</li>
</ul>
<h2>Summary</h2>
<ul>
<li>Your ORM already describes the schema. Export it as SQL into a schema file: a short script for SQLAlchemy, <code>drizzle-kit export</code> for Drizzle, <code>pg_dump</code> after <code>AutoMigrate</code> for GORM.</li>
<li><code>migrax generate</code> writes the next migration and its rollback script, asks about renames, and refuses to drop data without your confirmation.</li>
<li><code>migrax check</code> in CI catches a model change without a migration, and <code>migrax verify</code> proves the migrations build exactly the schema in the file.</li>
<li>The migrations are plain SQL. Your application runs without Migrax.</li>
</ul>
<p>The full reference, with MySQL and more ORMs: <a href="https://migrax.org/guides/schema-from-orm/">Schema file from your ORM</a>.</p>
<hr />
<p><em>Originally published at <a href="https://migrax.org/blog/generated-migrations-for-python-typescript-and-go/">migrax.org</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Migrax vs Flyway: generated vs hand-written migrations]]></title><description><![CDATA[Flyway and Migrax both keep a database schema in numbered SQL files, apply them in order and record what ran. The difference is where the SQL comes from…]]></description><link>https://fsmutimeer.hashnode.dev/migrax-vs-flyway-generated-vs-hand-written-migrations</link><guid isPermaLink="true">https://fsmutimeer.hashnode.dev/migrax-vs-flyway-generated-vs-hand-written-migrations</guid><category><![CDATA[Java]]></category><category><![CDATA[flyway]]></category><category><![CDATA[database]]></category><dc:creator><![CDATA[Feroz Shah]]></dc:creator><pubDate>Sat, 31 Oct 2026 12:03:19 GMT</pubDate><content:encoded><![CDATA[<p>Flyway and Migrax both keep a database schema in numbered SQL files, apply them in order and record what ran. The difference is where the SQL comes from. With Flyway you write every migration yourself. With Migrax you change your JPA entities and it writes the migration, which you then review.</p>
<p>This article makes the same change with both, on the same PostgreSQL database, and compares what each one does along the way. It also says where Flyway is the better choice.</p>
<blockquote>
<p><strong>Tested with:</strong> Flyway Community 13.12.0 (the open-source command line, released 2026-10-30), Migrax 0.17.1, Spring Boot 3.5.7, Hibernate 6.6.33, PostgreSQL 17. Compared on 2026-10-31. Statements about Flyway's editions come from Redgate's <a href="https://documentation.red-gate.com/fd/feature-summary-165740620.html">feature summary</a> (last updated 2026-10-27). Every output below was copied from a real run.</p>
</blockquote>
<h2>The change</h2>
<p>A <code>customers</code> table with two rows, and the change from <a href="https://migrax.org/blog/stop-using-ddl-auto-update-in-production/">Stop using <code>ddl-auto=update</code> in production</a>: <code>name</code> becomes <code>full_name</code>, <code>email</code> gets longer, and every customer needs a <code>country</code>.</p>
<h2>With Flyway</h2>
<p>You write the migration. A correct version keeps the data by renaming the column, and adds the required column in three steps because the table has rows:</p>
<p><code>V2__rename_name_add_country.sql</code>:</p>
<pre><code class="language-sql">ALTER TABLE customers RENAME COLUMN name TO full_name;
ALTER TABLE customers ADD COLUMN country varchar(2);
UPDATE customers SET country = 'GB';
ALTER TABLE customers ALTER COLUMN country SET NOT NULL;
ALTER TABLE customers ALTER COLUMN email TYPE varchar(320);
</code></pre>
<pre><code class="language-shell">$ flyway migrate
Current version of schema "public": 1
Migrating schema "public" to version "2 - rename name add country"
Successfully applied 1 migration to schema "public", now at version v2 (execution time 00:00.019s)
</code></pre>
<p>Flyway runs what you wrote, exactly as written. Whether the file matches the entities, and whether it keeps the data, is up to you and your reviewers. If you forget the <code>UPDATE</code>, or write <code>DROP COLUMN name</code> and <code>ADD COLUMN full_name</code> instead of a rename, Flyway runs that too.</p>
<p>There is no way back with the open-source edition:</p>
<pre><code class="language-shell">$ flyway undo
ERROR: Flyway Redgate Edition Required: undo is not supported by OSS Edition
</code></pre>
<p>Undo scripts (<code>U2__...sql</code>) and the <code>undo</code> command are part of Flyway Teams and Enterprise, and you write each undo script yourself.</p>
<h2>With Migrax</h2>
<p>You change the entity (<code>fullName</code>, <code>length = 320</code>, a new <code>country</code> field) and run <code>generate</code>:</p>
<pre><code class="language-shell">$ migrax generate --rename customers.name=full_name
warning: Adding NOT NULL column 'customers.country' without a default fails if the table has rows. Add a default in the SQL, or add it as nullable, backfill, then make it NOT NULL.
Created src/main/resources/db/migration/0002_rename_customers_name_and_more.sql:
  - rename column customers.name to full_name
  - add column customers.country
  - alter column customers.email
Rollback script written to src/main/resources/db/migration/rollback.
...
</code></pre>
<p>It writes the forward migration and a rollback script, and lints the migration before anything runs. The warning above is the <code>UPDATE</code> you would otherwise have to remember. You edit the file the same way as the Flyway one, and end up with the same five statements. Without <code>--rename</code>, Migrax sees a column go away and another appear, asks whether it was a rename, and refuses to drop the column without <code>--allow-destructive</code>. In CI, where no one can answer, it stops and prints the <code>--rename</code> option to use.</p>
<p><code>migrax rollback</code> runs the generated rollback script:</p>
<p><code>rollback/0002_rename_customers_name_and_more.sql</code>:</p>
<pre><code class="language-sql">ALTER TABLE customers DROP COLUMN country;
ALTER TABLE customers ALTER COLUMN email TYPE varchar(255);
ALTER TABLE customers RENAME COLUMN full_name TO name;
</code></pre>
<p>So the work moves from writing SQL to reviewing it. You still read every migration; you no longer type it, or its undo script.</p>
<h2>What both do</h2>
<p>Much of the day-to-day is the same:</p>
<ul>
<li><p><strong>Plain SQL files in order.</strong> Flyway's <code>V1__...sql</code>, Migrax's <code>0001_...sql</code>. Both also run Java migrations for changes SQL can't express.</p>
</li>
<li><p><strong>A history table with checksums.</strong> Both refuse to continue when someone edits a migration that already ran:</p>
<pre><code class="language-shell">
$ migrax migrate error[MXE104]: Checksum changed for applied migration V1__create_customers.sql ```
</code></pre>
</li>
<li><p><strong>Baselines</strong> for a database that existed before the tool: <code>flyway baseline</code> and <code>migrax baseline</code>.</p>
</li>
<li><p><strong>Running at application startup</strong>, through Spring Boot's built-in Flyway support or the Migrax starter, and both have Maven and Gradle plugins.</p>
</li>
</ul>
<h2>Where they differ</h2>
<table>
<thead>
<tr>
<th></th>
<th>Flyway Community</th>
<th>Flyway Teams / Enterprise</th>
<th>Migrax</th>
</tr>
</thead>
<tbody><tr>
<td>Who writes the SQL</td>
<td>You</td>
<td>You; Enterprise can generate it from a schema model</td>
<td>Generated from JPA entities, you review it</td>
</tr>
<tr>
<td>Undo / rollback</td>
<td>No</td>
<td>Teams and Enterprise: undo scripts you write</td>
<td>Rollback script generated with each migration</td>
</tr>
<tr>
<td>Dry run script</td>
<td>No</td>
<td>Yes</td>
<td><code>migrax migrate --sql</code> prints the script without running it</td>
</tr>
<tr>
<td>Drift detection</td>
<td>No</td>
<td>Enterprise</td>
<td><code>migrax drift</code>, and a check at startup</td>
</tr>
<tr>
<td>Lint for risky statements</td>
<td>No</td>
<td>Not in the feature summary</td>
<td><code>migrax lint</code>, run on every <code>generate</code></td>
</tr>
<tr>
<td>Throwaway-database check</td>
<td>No</td>
<td>Not in the feature summary</td>
<td><code>migrax verify</code> applies every migration and rollback</td>
</tr>
<tr>
<td>Databases</td>
<td>About 30, including Snowflake, BigQuery and MongoDB (<a href="https://documentation.red-gate.com/fd/supported-databases-and-versions-143754067.html">list</a>)</td>
<td>Same, with Redgate support</td>
<td>12 relational databases</td>
</tr>
<tr>
<td>Licence</td>
<td>Apache 2.0</td>
<td>Commercial</td>
<td>Apache 2.0, every feature</td>
</tr>
</tbody></table>
<p>"Not in the feature summary" means Redgate's feature summary doesn't list it; Redgate's documentation describes the checks each edition has.</p>
<h2>Where Flyway is the better choice</h2>
<ul>
<li><strong>There are no JPA entities.</strong> Migrax generates migrations from Hibernate, JPA and a few other Java mappings (or a SQL schema file). A project that writes SQL by hand, or isn't on the JVM, gains less from it, while Flyway works the same for everyone.</li>
<li><strong>The database isn't one of Migrax's 12.</strong> Flyway reaches warehouses and document databases such as Snowflake, BigQuery, Databricks and MongoDB.</li>
<li><strong>You want a vendor behind the tool.</strong> Redgate sells support, a desktop application and compliance features. Migrax is an open-source project.</li>
<li><strong>The team prefers writing every statement.</strong> Some teams want the migration to be the source of truth, not the entities. Flyway is built around that.</li>
</ul>
<h2>Switching from Flyway</h2>
<p>Migrax reads Flyway's history table, so a project can switch without rerunning anything. Copy or keep the <code>V*__</code> files in the migration folder and run:</p>
<pre><code class="language-shell">$ migrax import flyway
Recorded 2 migration(s) as applied.
Next: disable Flyway (spring.flyway.enabled=false), then run 'migrax generate'; its first run uses the current database as the baseline.

$ migrax status
Migrations in src/main/resources/db/migration for jdbc:postgresql://localhost:5432/shop_fw
  [X] V1__create_customers.sql         applied 2026-10-31 11:45:20
  [X] V2__rename_name_add_country.sql  applied 2026-10-31 11:45:20
2 applied.

$ migrax generate
No snapshot yet: using the current database schema as the baseline.
No changes detected.
Saved the entity schema as the baseline snapshot: .migrax/snapshot.json
</code></pre>
<p>The old files keep working, and the next entity change becomes <code>0003_...sql</code>. Each other environment needs <code>migrax import flyway</code> once. The guide <a href="https://migrax.org/guides/switching-tools/">Switching from Flyway or Liquibase</a> has the details.</p>
<h2>Summary</h2>
<ul>
<li>Flyway applies the SQL you write. Migrax writes the SQL from your entities, with a rollback script and lint warnings, and you review it.</li>
<li>Both keep a checksummed history, refuse edited migrations, baseline existing databases and run at startup.</li>
<li>Undo, dry-run scripts and drift detection are paid Flyway features; in Migrax they are part of the free tool.</li>
<li>Flyway is the better fit without JPA entities, for databases Migrax doesn't support, or when you want commercial support.</li>
</ul>
<hr />
<p><em>Originally published at <a href="https://migrax.org/blog/migrax-vs-flyway/">migrax.org</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Catching schema drift before it breaks production]]></title><description><![CDATA[Migrations only describe a database as long as nobody changes it any other way. Then someone adds a column during an incident, a DBA widens a type, a data…]]></description><link>https://fsmutimeer.hashnode.dev/catching-schema-drift-before-it-breaks-production</link><guid isPermaLink="true">https://fsmutimeer.hashnode.dev/catching-schema-drift-before-it-breaks-production</guid><category><![CDATA[Java]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[database]]></category><dc:creator><![CDATA[Feroz Shah]]></dc:creator><pubDate>Sat, 31 Oct 2026 12:00:35 GMT</pubDate><content:encoded><![CDATA[<p>Migrations only describe a database as long as nobody changes it any other way. Then someone adds a column during an incident, a DBA widens a type, a data import relaxes a constraint, and nobody writes it down. The next migration was tested against a database that production no longer is.</p>
<p>That gap is called drift. This article makes four changes by hand on a production database, finds them with <code>migrax drift</code>, keeps the one that was wanted, undoes the rest, and then makes the application refuse to start on a database that has drifted.</p>
<blockquote>
<p><strong>Tested with:</strong> Migrax 0.17.1, Spring Boot 3.5.7, Hibernate 6.6.33, PostgreSQL 17. Every output below was copied from a real run; log lines are shortened to the message.</p>
</blockquote>
<h2>The starting point</h2>
<p>A Spring Boot application with a <code>customers</code> table, built by two migrations and applied to production:</p>
<pre><code class="language-java">@Entity
@Table(name = "customers")
public class Customer {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false, unique = true, length = 320)
  private String email;

  @Column(nullable = false, length = 100)
  private String fullName;

  @Column(nullable = false, length = 2)
  private String country = "GB";
}
</code></pre>
<p>Here production is the database <code>shop_prod</code> and development is <code>shop</code>, on the same PostgreSQL server. <code>.migrax/snapshot.json</code> records the schema those migrations produce. <code>generate</code> keeps it up to date and it is committed with the migrations, so it is also the yardstick for drift.</p>
<h2>Four changes nobody wrote down</h2>
<p>During a busy week, four statements run on production by hand:</p>
<pre><code class="language-sql">ALTER TABLE customers ADD COLUMN vip boolean NOT NULL DEFAULT false;
ALTER TABLE customers ALTER COLUMN full_name DROP NOT NULL;
ALTER TABLE customers ALTER COLUMN email TYPE varchar(200);
CREATE INDEX customers_country_idx ON customers (country);
</code></pre>
<p>The first is a hotfix for a feature that is coming anyway. The second was needed for a data import and never reverted. The third was a mistake. The fourth sped up a slow report.</p>
<h2>Finding them</h2>
<pre><code class="language-shell">$ migrax drift --url jdbc:postgresql://localhost:5432/shop_prod
3 difference(s) between the database and the snapshot:
  - customers.email: expected varchar(320) but the database column is shorter (varchar(200))
  - customers.full_name: expected NOT NULL but the database has NULL
  - customers.vip: column exists in the database but not in the expected schema
Someone changed the database outside migrations. Write a migration for intended changes ('migrax new &lt;name&gt;'), or revert them.
</code></pre>
<p><code>drift</code> reads the live schema and compares it with the snapshot. It checks tables, columns, types, nullability, primary and foreign keys and unique constraints. Plain indexes are not part of that comparison, so <code>customers_country_idx</code> isn't listed; <code>migrax diff</code>, further down, does show it.</p>
<p>The shorter <code>email</code> is the dangerous one. The entity allows 320 characters, so the first customer with a long address gets an error from the database. Nothing in the code or the migrations would show why.</p>
<p><code>drift</code> exits with code 2 when it finds differences, and <code>--json</code> gives the same result for a script:</p>
<pre><code class="language-shell">$ migrax drift --url jdbc:postgresql://localhost:5432/shop_prod --json
{"against":"snapshot","pending":0,"differences":[{"kind":"type","object":"customers.email","detail":"expected varchar(320) but the database column is shorter (varchar(200))"},{"kind":"nullability","object":"customers.full_name","detail":"expected NOT NULL but the database has NULL"},{"kind":"extra_column","object":"customers.vip","detail":"column exists in the database but not in the expected schema"}]}
$ echo $?
2
</code></pre>
<p>With <code>--entities</code>, <code>drift</code> compares the database with the entities instead of the snapshot. That answers a different question: not "did someone change the database?" but "does the database fit the code I have now?"</p>
<h2>Keeping the change you want</h2>
<p>The <code>vip</code> column is wanted, so it belongs in the entity and in a migration, like every other change:</p>
<pre><code class="language-java">  @ColumnDefault("false")
  @Column(nullable = false)
  private boolean vip;
</code></pre>
<pre><code class="language-shell">$ migrax generate
Created src/main/resources/db/migration/0003_add_customers_vip.sql:
  - add column customers.vip
Rollback script written to src/main/resources/db/migration/rollback.
Review the SQL, then run 'migrax migrate'.
</code></pre>
<pre><code class="language-sql">ALTER TABLE customers ADD COLUMN vip boolean DEFAULT false NOT NULL;
</code></pre>
<p>Every other database (development, staging, a new laptop) needs this statement. Production already has the column and would fail on it, so add <code>IF NOT EXISTS</code>:</p>
<pre><code class="language-sql">ALTER TABLE customers ADD COLUMN IF NOT EXISTS vip boolean DEFAULT false NOT NULL;
</code></pre>
<p>The migration now runs everywhere, and production records it like any other:</p>
<pre><code class="language-shell">$ migrax migrate --url jdbc:postgresql://localhost:5432/shop_prod
Applying migration '0003_add_customers_vip.sql'...
Successfully applied migration '0003_add_customers_vip.sql'.
Applied 1 migration(s); 3 total in src/main/resources/db/migration.

$ migrax drift --url jdbc:postgresql://localhost:5432/shop_prod
2 difference(s) between the database and the snapshot:
  - customers.email: expected varchar(320) but the database column is shorter (varchar(200))
  - customers.full_name: expected NOT NULL but the database has NULL
Someone changed the database outside migrations. Write a migration for intended changes ('migrax new &lt;name&gt;'), or revert them.
</code></pre>
<h2>Undoing the rest</h2>
<p>For the changes that shouldn't be there, <code>migrax diff</code> writes the SQL. It compares two live databases: the source is one that is right (here the development database, which only ever got migrations), the target is production. The source's credentials come from <code>MIGRAX_SOURCE_USER</code> and <code>MIGRAX_SOURCE_PASSWORD</code>, or <code>--source-user</code> and
<code>--source-password-file</code>:</p>
<pre><code class="language-shell">$ migrax diff jdbc:postgresql://localhost:5432/shop --url jdbc:postgresql://localhost:5432/shop_prod
To make jdbc:postgresql://localhost:5432/shop_prod match jdbc:postgresql://localhost:5432/shop:
3 change(s) for postgresql:
  1. drop index customers_country_idx on customers  [DESTRUCTIVE]
     DROP INDEX customers_country_idx;
  2. alter column customers.email
     ALTER TABLE customers ALTER COLUMN email TYPE varchar(320);
  3. alter column customers.full_name
     ALTER TABLE customers ALTER COLUMN full_name SET NOT NULL;
To keep them, save the SQL as a migration: migrax diff &lt;source-url&gt; --sql
</code></pre>
<p><code>diff</code> also compares indexes, so the report index shows up here. <code>--sql</code> prints the same statements as one script to review and hand to whoever runs SQL on production:</p>
<pre><code class="language-sql">-- Generated by Migrax 0.17.1 for postgresql

DROP INDEX customers_country_idx;
ALTER TABLE customers ALTER COLUMN email TYPE varchar(320);
ALTER TABLE customers ALTER COLUMN full_name SET NOT NULL;
</code></pre>
<p>Keep the lines you mean to run. If the index is worth having, leave out the <code>DROP INDEX</code> and add the index to the entity (<code>@Table(indexes = ...)</code>) so the next <code>generate</code> writes it for everyone else. <code>SET NOT NULL</code> fails if the import left rows without a name, so fill those in first. Once the script has run:</p>
<pre><code class="language-shell">$ migrax drift --url jdbc:postgresql://localhost:5432/shop_prod
No drift: the database matches the snapshot.

$ migrax diff jdbc:postgresql://localhost:5432/shop --url jdbc:postgresql://localhost:5432/shop_prod
Same schema: jdbc:postgresql://localhost:5432/shop_prod matches jdbc:postgresql://localhost:5432/shop.
</code></pre>
<h2>Noticing it the next time</h2>
<p>Finding drift by chance is too late. There are two places to check for it on purpose.</p>
<p><strong>On a schedule.</strong> Because <code>drift</code> exits with 2, a nightly CI job or Kubernetes CronJob fails when it finds something, and the usual alert for a failed job tells you the next morning.</p>
<p><strong>When the application starts.</strong> With the <a href="https://migrax.org/frameworks/spring-boot/">Spring Boot starter</a>, the application runs the same comparison each time it starts, right after it applies any pending migrations. Turn it on with one line:</p>
<pre><code class="language-properties">migrax.drift.check=fail
</code></pre>
<p>The application reads the expected schema from <code>META-INF/migrax/snapshot.json</code>, so the build has to put the snapshot there. With Maven, that is the <code>package-snapshot</code> goal of the Migrax plugin:</p>
<pre><code class="language-xml">&lt;plugin&gt;
  &lt;groupId&gt;org.migrax&lt;/groupId&gt;
  &lt;artifactId&gt;migrax&lt;/artifactId&gt;
  &lt;version&gt;0.17.1&lt;/version&gt;
  &lt;executions&gt;
    &lt;execution&gt;
      &lt;goals&gt;
        &lt;goal&gt;package-snapshot&lt;/goal&gt;
      &lt;/goals&gt;
    &lt;/execution&gt;
  &lt;/executions&gt;
&lt;/plugin&gt;
</code></pre>
<p>(The Gradle plugin and the Quarkus extension copy it without any setup.)</p>
<p>A week later someone adds another column by hand, and the next deployment stops:</p>
<pre><code class="language-text">Migrax: schema is up to date
Migrax: 1 difference(s) between the database and the schema its migrations produce:
  - customers.hotfix_note: column exists in the database but not in the expected schema
Someone changed the database outside migrations. Write a migration for intended changes, or revert them.
(migrax.drift.check=fail stops the start; warn only logs.)
</code></pre>
<p>The change is noticed at the deployment, not weeks later when something breaks. On a database without drift, the log says so and the application starts:</p>
<pre><code class="language-text">Migrax: schema is up to date
Migrax: no drift: the database matches the schema its migrations produce
Started ShopApplication in 6.757 seconds (process running for 7.945)
</code></pre>
<p><code>migrax.drift.check=warn</code> logs the same differences without stopping the start, which is a gentler way to begin. Quarkus, Micronaut, Helidon and Jakarta EE take the same setting.</p>
<h2>Summary</h2>
<ul>
<li><code>migrax drift</code> compares a live database with the schema its migrations produce and exits with 2 when someone changed it by hand. <code>--json</code> is for scripts, <code>--entities</code> compares with the code instead.</li>
<li>A hand-made change you want goes into the entity and a migration. <code>IF NOT EXISTS</code> lets the database that already has it run the migration too.</li>
<li><code>migrax diff</code> writes the SQL that undoes the rest, by comparing with a database that only ever got migrations. Unlike <code>drift</code>, it also compares indexes.</li>
<li>Run <code>drift</code> on a schedule, or set <code>migrax.drift.check=fail</code> so the application doesn't start on a drifted database.</li>
</ul>
<p>More in the guides: <a href="https://migrax.org/guides/production-safety/">Production safety</a> and <a href="https://migrax.org/guides/comparing-databases/">Comparing databases</a>.</p>
<hr />
<p><em>Originally published at <a href="https://migrax.org/blog/catching-schema-drift/">migrax.org</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Stop using ddl-auto=update in production]]></title><description><![CDATA[spring.jpa.hibernate.ddl-auto=update is the fastest way to get started: change an entity, restart, and the table follows. It stays in the configuration…]]></description><link>https://fsmutimeer.hashnode.dev/stop-using-ddl-auto-update-in-production</link><guid isPermaLink="true">https://fsmutimeer.hashnode.dev/stop-using-ddl-auto-update-in-production</guid><category><![CDATA[Java]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[database]]></category><dc:creator><![CDATA[Feroz Shah]]></dc:creator><pubDate>Sat, 31 Oct 2026 11:58:31 GMT</pubDate><content:encoded><![CDATA[<p><code>spring.jpa.hibernate.ddl-auto=update</code> is the fastest way to get started: change an entity, restart, and the table follows. It stays in the configuration long after the first demo, and one day it runs against a database with real customers in it.</p>
<p>This article makes three ordinary entity changes with <code>update</code> switched on and shows what Hibernate does to the database. Then it moves the same project, with its existing data, to generated migrations that you review before they run.</p>
<blockquote>
<p><strong>Tested with:</strong> Migrax 0.17.1, Spring Boot 3.5.7, Hibernate 6.6.33, PostgreSQL 17. Every output below was copied from a real run; log lines are shortened to the message.</p>
</blockquote>
<h2>The starting point</h2>
<p>A Spring Boot application with one entity:</p>
<pre><code class="language-java">@Entity
@Table(name = "customers")
public class Customer {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false, unique = true)
  private String email;

  @Column(nullable = false, length = 100)
  private String name;
}
</code></pre>
<p>and the setting this article is about:</p>
<pre><code class="language-properties">spring.jpa.hibernate.ddl-auto=update
</code></pre>
<p>On the first start Hibernate creates the table. Two customers sign up:</p>
<pre><code class="language-text"> id |      email       |     name
----+------------------+--------------
  1 | ada@example.com  | Ada Lovelace
  2 | alan@example.com | Alan Turing
</code></pre>
<p>So far <code>update</code> has done exactly what it promises.</p>
<h2>Three ordinary changes</h2>
<p>The next release changes the entity in three small ways:</p>
<ul>
<li><code>name</code> becomes <code>fullName</code>,</li>
<li><code>email</code> gets longer (<code>length = 320</code>),</li>
<li>every customer now needs a <code>country</code> (<code>nullable = false</code>, <code>"GB"</code> by default in Java).</li>
</ul>
<pre><code class="language-java">  @Column(nullable = false, unique = true, length = 320)
  private String email;

  @Column(nullable = false, length = 100)
  private String fullName;

  @Column(nullable = false, length = 2)
  private String country = "GB";
</code></pre>
<p>The application starts, and the next customer tries to sign up. With <code>logging.level.org.hibernate.SQL=debug</code>, the log shows what <code>update</code> did:</p>
<pre><code class="language-text">alter table if exists customers add column country varchar(2) not null
GenerationTarget encountered exception accepting command : Error executing DDL "alter table
  if exists customers add column country varchar(2) not null" via JDBC [ERROR: column
  "country" of relation "customers" contains null values]
alter table if exists customers alter column email set data type varchar(320)
alter table if exists customers add column full_name varchar(100) not null
GenerationTarget encountered exception accepting command : Error executing DDL "alter table
  if exists customers add column full_name varchar(100) not null" via JDBC [ERROR: column
  "full_name" of relation "customers" contains null values]
Started ShopApplication in 5.803 seconds (process running for 6.91)
insert into customers (country,email,full_name) values (?,?,?)
ERROR: column "country" of relation "customers" does not exist
Application run failed
</code></pre>
<p>Three things happened, and only one of them was what you meant:</p>
<ol>
<li><strong>The rename isn't a rename.</strong> Hibernate has no way to know that <code>full_name</code> is the old <code>name</code>. It tries to add a new, empty column next to it.</li>
<li><strong>Adding the required columns fails.</strong> PostgreSQL can't add a <code>NOT NULL</code> column without a default to a table that already has rows. Hibernate logs a warning and carries on.</li>
<li><strong>The application starts anyway.</strong> The failures are logged as warnings and startup carries on. The error only shows up when the first customer signs up.</li>
</ol>
<p>Only the longer <code>email</code> worked.</p>
<h2>The quick fix makes it worse</h2>
<p>The obvious way out is to drop <code>nullable = false</code> from the two new fields so Hibernate can add them. That works, in the sense that the columns appear:</p>
<pre><code class="language-text">alter table if exists customers add column country varchar(2)
alter table if exists customers add column full_name varchar(100)
Started ShopApplication in 6.557 seconds (process running for 7.569)
insert into customers (country,email,full_name) values (?,?,?)
ERROR: null value in column "name" of relation "customers" violates not-null constraint
Application run failed
</code></pre>
<p>The old <code>name</code> column is still there, still <code>NOT NULL</code>, and the entity no longer writes to it. Every signup fails. The table now looks like this:</p>
<pre><code class="language-text"> id |      email       |     name     | country | full_name
----+------------------+--------------+---------+-----------
  1 | ada@example.com  | Ada Lovelace |         |
  2 | alan@example.com | Alan Turing  |         |
</code></pre>
<p>The existing customers have no <code>full_name</code> and no <code>country</code>, so the application shows them without a name. Nothing recorded what changed or when, and there is no way to undo it except by hand.</p>
<p>None of this is a bug in Hibernate. <code>update</code> only ever adds: it never renames, never drops, never moves data, and doesn't stop the application when a statement fails. That is a safe choice for a tool that runs on every start without asking anyone, and it is the reason it shouldn't touch a database whose data matters.</p>
<h2>Switching to migrations</h2>
<p>The same change, done with Migrax. The database goes back to where it was before the release: two customers and a <code>name</code> column. The entity is back at version 1 too, because the switch is easiest between releases.</p>
<h3>1. Let <code>doctor</code> find the setting</h3>
<pre><code class="language-shell">$ migrax doctor
...
  [warn]  Hibernate will change your database schema by itself
          Found:  spring.jpa.hibernate.ddl-auto = update
                  in src/main/resources/application.properties, line 4

          Why:    Hibernate adds tables and columns itself when the application starts. Migrations
                  then fail with "already exists", and the database drifts from what your migrations
                  describe, so other environments end up different.

          Fix:    change   spring.jpa.hibernate.ddl-auto=update
                  to       spring.jpa.hibernate.ddl-auto=none
                  (or validate: Hibernate then checks the schema at startup without changing it)
  [info]  Snapshot: none yet; the first generate uses the database as baseline
  [ok]    Database URL: jdbc:postgresql://localhost:5432/shop
  [ok]    Connected: PostgreSQL 17.11 (Debian 17.11-1.pgdg13+2) (driver 42.7.8)
...
All checks passed, with 1 warning(s) above.
</code></pre>
<p>Migrax reads the database settings from <code>spring.datasource.*</code>, so there is nothing else to configure. Change the line to <code>validate</code>:</p>
<pre><code class="language-properties">spring.jpa.hibernate.ddl-auto=validate
</code></pre>
<p>With <code>validate</code>, Hibernate still checks at startup that every entity matches a table and column, and refuses to start if one doesn't. It just never changes anything.</p>
<h3>2. Record what the database already has</h3>
<p>The first <code>generate</code> has nothing to compare with yet, so it uses the live database:</p>
<pre><code class="language-shell">$ migrax generate
No snapshot yet: using the current database schema as the baseline.
No changes detected.
Created src/main/resources/db/migration/0001_baseline.sql with the 1 table(s) the database already has.
  It is recorded as applied in this database without running it; empty databases are created from it.
Saved the entity schema as the baseline snapshot: .migrax/snapshot.json
</code></pre>
<p><code>0001_baseline.sql</code> describes the table as it is:</p>
<pre><code class="language-sql">-- Generated by Migrax 0.17.1 for postgresql
-- Baseline: the tables the database already had before its first migration.
-- That database records it as applied without running it; empty databases are created from it.

CREATE TABLE customers (id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, email varchar(255) NOT NULL, name varchar(100) NOT NULL, CONSTRAINT customers_pkey PRIMARY KEY (id), CONSTRAINT uk_customers_email UNIQUE (email));
</code></pre>
<p>This database won't run it, because the table is already there. A new laptop, a test database or <code>migrax verify</code> starts empty and builds the table from it. "No changes detected" means the entities and the database agree, so nothing is lost by switching. If <code>update</code> had already left old columns behind, they would show up here as changes to review.</p>
<p>Commit the migration folder and <code>.migrax/snapshot.json</code>.</p>
<h3>3. Make the change again</h3>
<p>Now the same three changes to <code>Customer</code>. This time <code>generate</code> writes them down instead of running them:</p>
<pre><code class="language-shell">$ migrax generate --rename customers.name=full_name
warning: Adding NOT NULL column 'customers.country' without a default fails if the table has rows. Add a default in the SQL, or add it as nullable, backfill, then make it NOT NULL.
Created src/main/resources/db/migration/0002_rename_customers_name_and_more.sql:
  - rename column customers.name to full_name
  - add column customers.country
  - alter column customers.email
Rollback script written to src/main/resources/db/migration/rollback.
src/main/resources/db/migration/0002_rename_customers_name_and_more.sql, statement 1: warning MX008
  ALTER TABLE customers RENAME COLUMN name TO full_name
  Application instances still running the old code use the old name and will fail.
  ...
src/main/resources/db/migration/0002_rename_customers_name_and_more.sql, statement 2: warning MX002
  ALTER TABLE customers ADD COLUMN country varchar(2) NOT NULL
  Adding a NOT NULL column without a default fails when customers has rows.
  Fix: Add a DEFAULT, or add the column as nullable, backfill it, then make it NOT NULL.
...
Review the SQL, then run 'migrax migrate'.
</code></pre>
<p>Without <code>--rename</code>, Migrax notices that a column went away while another appeared and asks whether it was a rename; in CI it refuses to guess and stops. <a href="https://migrax.org/blog/renaming-columns-and-tables-without-losing-data/">Renaming columns and tables without losing data</a> shows both.</p>
<p>The warning MX002 is the failure from the <code>update</code> run, found before anything ran. The migration is a plain SQL file, so fix it the way the warning says: add the column, fill it, then make it required.</p>
<pre><code class="language-sql">-- Generated by Migrax 0.17.1 for postgresql

ALTER TABLE customers RENAME COLUMN name TO full_name;
ALTER TABLE customers ADD COLUMN country varchar(2);
UPDATE customers SET country = 'GB';
ALTER TABLE customers ALTER COLUMN country SET NOT NULL;
ALTER TABLE customers ALTER COLUMN email TYPE varchar(320);
</code></pre>
<p>Next to it, <code>rollback/0002_rename_customers_name_and_more.sql</code> undoes the change:</p>
<pre><code class="language-sql">ALTER TABLE customers DROP COLUMN country;
ALTER TABLE customers ALTER COLUMN email TYPE varchar(255);
ALTER TABLE customers RENAME COLUMN full_name TO name;
</code></pre>
<h3>4. Apply it</h3>
<pre><code class="language-shell">$ migrax migrate
Applying migration '0002_rename_customers_name_and_more.sql'...
Successfully applied migration '0002_rename_customers_name_and_more.sql'.
Applied 1 migration(s); 2 total in src/main/resources/db/migration.
</code></pre>
<p>The application starts with <code>validate</code>, the next customer signs up, and the existing ones kept their names:</p>
<pre><code class="language-text"> id |       email       |  full_name   | country
----+-------------------+--------------+---------
  1 | ada@example.com   | Ada Lovelace | GB
  2 | alan@example.com  | Alan Turing  | GB
  3 | grace@example.com | Grace Hopper | GB
</code></pre>
<h2>The other environments</h2>
<p>Production, staging and every teammate's database have the same table but no migration history. Each one needs to be told once that the baseline is already there:</p>
<pre><code class="language-shell">$ migrax status
Migrations in src/main/resources/db/migration for jdbc:postgresql://localhost:5432/shop_prod
  [ ] 0001_baseline.sql                        pending
  [ ] 0002_rename_customers_name_and_more.sql  pending
2 pending.

$ migrax baseline 0001 --yes
Recording 1 migration(s) as applied without running them:
  0001_baseline.sql
Recorded. Run 'migrax migrate' to apply the migrations after 0001_baseline.sql.

$ migrax migrate
Applying migration '0002_rename_customers_name_and_more.sql'...
Successfully applied migration '0002_rename_customers_name_and_more.sql'.
Applied 1 migration(s); 2 total in src/main/resources/db/migration.
</code></pre>
<p>(Point each command at that database with <code>--url</code>, or the environment variables your deployment already uses.)</p>
<p>Forgetting <code>baseline</code> is safe. <code>migrate</code> tries to create the table, the database refuses, and nothing is recorded or changed:</p>
<pre><code class="language-shell">$ migrax migrate
Applying migration '0001_baseline.sql'...
error: Failed executing migration '0001_baseline.sql': ERROR: relation "customers" already exists
error[MXE102]: Migration '0001_baseline.sql' failed and was not recorded as applied. ...
</code></pre>
<p><code>baseline</code> then refuses too, because the failed attempt counts as history. Record the baseline with <code>repair</code> instead, and carry on:</p>
<pre><code class="language-shell">$ migrax repair 0001_baseline.sql --action applied --yes
Repaired history for 0001_baseline.sql (action: applied). Keep a record of this repair.

$ migrax migrate
Applying migration '0002_rename_customers_name_and_more.sql'...
Successfully applied migration '0002_rename_customers_name_and_more.sql'.
Applied 1 migration(s); 2 total in src/main/resources/db/migration.
</code></pre>
<h2>Other frameworks</h2>
<p>The setting has a different name outside Spring Boot, and <code>migrax doctor</code> finds each of them:</p>
<table>
<thead>
<tr>
<th>Framework</th>
<th>Setting</th>
<th>Change to</th>
</tr>
</thead>
<tbody><tr>
<td>Spring Boot</td>
<td><code>spring.jpa.hibernate.ddl-auto</code></td>
<td><code>validate</code> or <code>none</code></td>
</tr>
<tr>
<td>Quarkus</td>
<td><code>quarkus.hibernate-orm.schema-management.strategy</code></td>
<td><code>validate</code> or <code>none</code></td>
</tr>
<tr>
<td>Micronaut</td>
<td><code>jpa.default.properties.hibernate.hbm2ddl.auto</code></td>
<td><code>validate</code> or <code>none</code></td>
</tr>
<tr>
<td>Plain Hibernate, Jakarta EE</td>
<td><code>hibernate.hbm2ddl.auto</code></td>
<td><code>validate</code> or <code>none</code></td>
</tr>
</tbody></table>
<h2>Summary</h2>
<ul>
<li><code>ddl-auto=update</code> only adds. It turns a rename into a second, empty column, can't add a required column to a table with rows, and lets the application start when a statement fails.</li>
<li>Switch between releases: set <code>validate</code>, run <code>migrax generate</code> once to record the existing tables as <code>0001_baseline.sql</code>, and run <code>migrax baseline 0001 --yes</code> once on every other database.</li>
<li>From then on every change is a SQL file you review, with lint warnings for the statements that fail on real data, and a rollback script.</li>
</ul>
<p>More in the guides: <a href="https://migrax.org/getting-started/quickstart/">Quick start</a> and <a href="https://migrax.org/guides/production-safety/">Production safety</a>.</p>
<hr />
<p><em>Originally published at <a href="https://migrax.org/blog/stop-using-ddl-auto-update-in-production/">migrax.org</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Applying and undoing migrations: migrate, status, rollback]]></title><description><![CDATA[migrax generate writes migrations, and three commands handle the rest: status shows what the database has, migrate applies what is missing, and rollback…]]></description><link>https://fsmutimeer.hashnode.dev/applying-and-undoing-migrations-migrate-status-rollback</link><guid isPermaLink="true">https://fsmutimeer.hashnode.dev/applying-and-undoing-migrations-migrate-status-rollback</guid><category><![CDATA[Java]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[database]]></category><dc:creator><![CDATA[Feroz Shah]]></dc:creator><pubDate>Sun, 25 Oct 2026 16:37:52 GMT</pubDate><content:encoded><![CDATA[<p><code>migrax generate</code> writes migrations, and three commands handle the rest: <code>status</code> shows what the database has, <code>migrate</code> applies what is missing, and <code>rollback</code> undoes it. This article goes through each one on a real project. It also covers what a rollback can and can't bring back, and what happens when someone edits a migration that already ran.</p>
<blockquote>
<p><strong>Tested with:</strong> Migrax 0.17.1, Hibernate 6.6.13, PostgreSQL 17. Every output below was copied from a real run.</p>
</blockquote>
<h2>Where we start</h2>
<p>The shop project from the earlier articles has four applied migrations: the first tables, a <code>phone</code> column, and the two renames from <a href="https://migrax.org/blog/renaming-columns-and-tables-without-losing-data/">Renaming columns and tables without losing data</a>. Now two more entity changes come in: a <code>notes</code> field on <code>PurchaseOrder</code>, and an index on its <code>createdAt</code> column.</p>
<pre><code class="language-java">@Entity
@Table(name = "purchase_orders",
    indexes = @Index(name = "ix_orders_created", columnList = "createdAt"))
public class PurchaseOrder {
  ...
  @Column(length = 500)
  String notes;
}
</code></pre>
<p>Running <code>migrax generate</code> once per change gives two files: <code>0005_add_purchase_orders_notes.sql</code> and <code>0006_index_purchase_orders.sql</code>.</p>
<h2><code>status</code>: what has the database got?</h2>
<pre><code class="language-shell">$ migrax status
Migrations in src/main/resources/db/migration for jdbc:postgresql://localhost:5432/shop
  [X] 0001_initial.sql                    applied 2026-10-25 16:04:44
  [X] 0002_add_customers_phone.sql        applied 2026-10-25 16:04:58
  [X] 0003_rename_customers_name.sql      applied 2026-10-25 16:06:06
  [X] 0004_rename_orders.sql              applied 2026-10-25 16:07:06
  [ ] 0005_add_purchase_orders_notes.sql  pending
  [ ] 0006_index_purchase_orders.sql      pending
4 applied, 2 pending.
</code></pre>
<p><code>[X]</code> is applied, <code>[ ]</code> is pending. Migrax keeps the history in a <code>migrax_history</code> table in the same database, with a checksum of every applied file. <code>status</code> only reads; it never changes anything. The alias <code>showmigrations</code> does the same.</p>
<h2>Before applying: <code>--dry-run</code> and <code>--sql</code></h2>
<p><code>--dry-run</code> lists what would run and checks it for risky SQL, without touching the database:</p>
<pre><code class="language-shell">$ migrax migrate --dry-run
2 migration(s) would be applied:
  0005_add_purchase_orders_notes.sql
  0006_index_purchase_orders.sql
src/main/resources/db/migration/0006_index_purchase_orders.sql, statement 1: warning MX001
  CREATE INDEX ix_orders_created ON purchase_orders (createdAt)
  Building this index blocks writes to purchase_orders until it finishes.
  Fix: Use CREATE INDEX CONCURRENTLY in its own migration that starts with '-- migrax:no-transaction' ('migrax generate --safe' does this).
</code></pre>
<p>On a small table that warning doesn't matter. On a table with millions of rows, it is the difference between a quick deploy and minutes of blocked writes. The <a href="https://migrax.org/reference/lint-rules/">lint rules</a> page lists every check.</p>
<p>If a DBA has to run the changes, <code>--sql</code> prints one script for them. It applies each migration in its own transaction and records it in the history, so Migrax sees it as applied afterwards:</p>
<pre><code class="language-shell">$ migrax migrate --sql
-- Migration script written by Migrax: 2 migration(s) to apply.
-- Review it, then run the whole file with the database's own tool; it must stop
-- at the first error:
--   psql -v ON_ERROR_STOP=1 -f &lt;this file&gt;
-- Each migration is recorded in migrax_history, so 'migrax status' and later
-- 'migrax migrate' runs see it as applied.

-- 0005_add_purchase_orders_notes.sql
BEGIN;
-- Generated by Migrax 0.17.1 for postgresql

ALTER TABLE purchase_orders ADD COLUMN notes varchar(500);
INSERT INTO migrax_history(version,checksum,applied_at) VALUES('0005_add_purchase_orders_notes.sql','5f26d7ff0f5e8502d49b57c00fd7449f0619bcf7d8f38441f006fcd261904837',(CURRENT_TIMESTAMP AT TIME ZONE 'Etc/UTC'));
COMMIT;

-- 0006_index_purchase_orders.sql
...
</code></pre>
<h2><code>migrate</code>: all at once, or up to a point</h2>
<p><code>migrax migrate</code> applies every pending migration in order. <code>--to</code> stops after the one you name:</p>
<pre><code class="language-shell">$ migrax migrate --to 0005_add_purchase_orders_notes.sql
Applying migration '0005_add_purchase_orders_notes.sql'...
Successfully applied migration '0005_add_purchase_orders_notes.sql'.
Applied 1 migration(s) up to 0005_add_purchase_orders_notes.sql.
1 migration(s) after it stay pending.
</code></pre>
<pre><code class="language-shell">$ migrax migrate
Applying migration '0006_index_purchase_orders.sql'...
Successfully applied migration '0006_index_purchase_orders.sql'.
Applied 1 migration(s); 6 total in src/main/resources/db/migration.
</code></pre>
<p>On PostgreSQL each migration runs in a transaction. If a statement fails, that migration is rolled back as a whole and <code>migrate</code> stops. Migrax also takes a lock on the database while it works, so two application instances that start at the same moment don't both apply the same migration.</p>
<h2><code>rollback</code>: undoing the newest migrations</h2>
<p>Every generated migration has a rollback script next to it, in <code>rollback/</code>. <code>migrax rollback</code> runs the script of the newest applied migration and removes that migration from the history.</p>
<p>Because a rollback can delete data, Migrax wants a confirmation. In a terminal it asks:</p>
<pre><code class="language-shell">$ migrax rollback
Rolling back 1 migration(s), newest first:
  0006_index_purchase_orders.sql
Roll back now? Data written to removed objects is lost. [y/N] y
Rolling back migration '0006_index_purchase_orders.sql'...
Rolled back migration '0006_index_purchase_orders.sql'.
Rolled back 1 migration(s).
</code></pre>
<p>In a script or CI job it stops instead, until you add <code>--yes</code>:</p>
<pre><code class="language-shell">$ migrax rollback
Rolling back 1 migration(s), newest first:
  0006_index_purchase_orders.sql
error[MXE001]: Rollback needs confirmation.
Back up the database, then rerun with --yes.
</code></pre>
<p><code>--steps n</code> undoes the newest <code>n</code> migrations. <code>--dry-run</code> shows the list first:</p>
<pre><code class="language-shell">$ migrax rollback --steps 2 --dry-run
Would roll back 2 migration(s), newest first:
  0006_index_purchase_orders.sql
  0005_add_purchase_orders_notes.sql
</code></pre>
<p><code>--to</code> keeps the migration you name and undoes everything after it:</p>
<pre><code class="language-shell">$ migrax rollback --to 0003_rename_customers_name.sql --yes
Rolling back 3 migration(s), newest first:
  0006_index_purchase_orders.sql
  0005_add_purchase_orders_notes.sql
  0004_rename_orders.sql
Rolling back migration '0006_index_purchase_orders.sql'...
Rolled back migration '0006_index_purchase_orders.sql'.
Rolling back migration '0005_add_purchase_orders_notes.sql'...
Rolled back migration '0005_add_purchase_orders_notes.sql'.
Rolling back migration '0004_rename_orders.sql'...
Rolled back migration '0004_rename_orders.sql'.
Rolled back 3 migration(s).
</code></pre>
<p>Rolled-back migrations are pending again, so the next <code>migrax migrate</code> applies them again. The files stay where they are.</p>
<h2>What a rollback can't bring back</h2>
<p>A rollback restores the structure, not the data. Say someone stores a note on an order:</p>
<pre><code class="language-sql">UPDATE purchase_orders SET notes = 'Gift wrap, please' WHERE id = 2;
</code></pre>
<p>Rolling back <code>0005</code> drops the <code>notes</code> column. Applying it again brings an empty column back, and the note is gone:</p>
<pre><code class="language-text"> id | notes
----+-------
  1 |
  2 |
  3 |
</code></pre>
<p>The rollback script says so in its header, so it shows in review before anything runs:</p>
<p><code>db/migration/rollback/0005_add_purchase_orders_notes.sql</code>:</p>
<pre><code class="language-sql">-- Generated by Migrax 0.17.1 for postgresql
-- Rollback for 0005_add_purchase_orders_notes.sql. 'migrax rollback' runs it.
-- Note: deletes data written to purchase_orders.notes since the migration ran.

ALTER TABLE purchase_orders DROP COLUMN notes;
</code></pre>
<p>The same applies the other way round: when a migration drops a column, its rollback can re-create the column but not its old values. To keep those, generate that migration with <code>--keep-data</code>. Migrax then copies the values into a backup table first, and the rollback puts them back (<a href="https://migrax.org/guides/rollbacks-and-recovery/">Rollbacks and recovery</a>).</p>
<h2>Don't edit a migration that already ran</h2>
<p>Migrax stores a checksum of each applied file. If someone changes the file afterwards, even just by adding a comment, <code>status</code> shows it and exits with code 1:</p>
<pre><code class="language-shell">$ migrax status
Migrations in src/main/resources/db/migration for jdbc:postgresql://localhost:5432/shop
  [X] 0001_initial.sql                    applied 2026-10-25 16:04:44
  [X] 0002_add_customers_phone.sql        applied 2026-10-25 16:04:58
  [X] 0003_rename_customers_name.sql      applied 2026-10-25 16:06:06
  [X] 0004_rename_orders.sql              applied 2026-10-25 16:08:46
  [!] 0005_add_purchase_orders_notes.sql  CHANGED: file changed after it was applied 2026-10-25 16:08:46
  [X] 0006_index_purchase_orders.sql      applied 2026-10-25 16:08:46
5 applied, 1 changed.
Some migrations need attention. See 'migrax help repair'.
</code></pre>
<p><code>migrate</code> refuses to run at all:</p>
<pre><code class="language-shell">$ migrax migrate
error[MXE104]: Checksum changed for applied migration 0005_add_purchase_orders_notes.sql
Run again with --verbose for details.
</code></pre>
<p>This protects you from a database that no longer matches its files: the edited SQL never ran on the databases that already applied the old version. Put the file back as it was (here, <code>git checkout</code> of the file) and write any new change as a new migration.</p>
<h2>Summary</h2>
<ul>
<li><code>migrax status</code> shows applied, pending and changed migrations, and never changes anything.</li>
<li><code>migrax migrate</code> applies pending migrations in order. <code>--dry-run</code> previews and lints, <code>--sql</code> writes a script for a DBA, and <code>--to</code> stops at a given migration.</li>
<li><code>migrax rollback</code> undoes the newest migrations (<code>--steps</code>, <code>--to</code>). It asks first, and needs <code>--yes</code> in scripts.</li>
<li>Rollbacks restore structure, not data. The rollback script's header says what gets lost, and <code>--keep-data</code> keeps it.</li>
<li>Never edit an applied migration. Write a new one.</li>
</ul>
<p>More in the guides: <a href="https://migrax.org/guides/rollbacks-and-recovery/">Rollbacks and recovery</a> and <a href="https://migrax.org/guides/production-safety/">Production safety</a>, which shows how <code>migrax verify</code> tests every rollback script before release.</p>
<hr />
<p><em>Originally published at <a href="https://migrax.org/blog/applying-and-undoing-migrations/">migrax.org</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Renaming columns and tables without losing data]]></title><description><![CDATA[Renames are where generated migrations usually go wrong. When a field changes from name to fullName, the database only sees one column disappear and…]]></description><link>https://fsmutimeer.hashnode.dev/renaming-columns-and-tables-without-losing-data</link><guid isPermaLink="true">https://fsmutimeer.hashnode.dev/renaming-columns-and-tables-without-losing-data</guid><category><![CDATA[Java]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[database]]></category><dc:creator><![CDATA[Feroz Shah]]></dc:creator><pubDate>Sun, 25 Oct 2026 16:35:52 GMT</pubDate><content:encoded><![CDATA[<p>Renames are where generated migrations usually go wrong. When a field changes from <code>name</code> to <code>fullName</code>, the database only sees one column disappear and another appear. A tool that just compares the two schemas drops the old column, and every value in it, then adds an empty new one.</p>
<p>Migrax notices when a change looks like a rename. In a terminal it asks you. In CI, where no one can answer, it refuses to write a migration that would lose the data. This article shows both cases for a column and for a table, and how to undo a rename.</p>
<blockquote>
<p><strong>Tested with:</strong> Migrax 0.17.1, Hibernate 6.6.13, PostgreSQL 17. Every output below was copied from a real run.</p>
</blockquote>
<h2>Where we start</h2>
<p>This picks up where <a href="https://migrax.org/blog/generate-sql-migrations-from-jpa-entities/">Generate SQL migrations from your JPA entities</a> ended: a <code>customers</code> table and an <code>orders</code> table, two migrations applied, and a few rows of data:</p>
<pre><code class="language-text">      email       | id |     name     |      phone
------------------+----+--------------+------------------
 ada@example.com  |  1 | Ada Lovelace | +44 20 7946 0001
 alan@example.com |  2 | Alan Turing  |
</code></pre>
<p>Each order points at its customer through a foreign key.</p>
<h2>Renaming a column</h2>
<p>Rename the field in <code>Customer</code>:</p>
<pre><code class="language-java">  @Column(name = "full_name", nullable = false, length = 100)
  String fullName;
</code></pre>
<h3>In CI: Migrax refuses</h3>
<p>First, run <code>generate</code> where nobody can answer a question, such as a CI job or a script:</p>
<pre><code class="language-shell">$ migrax generate
Possible rename: customers.name to customers.full_name. To keep the data, rerun with --rename customers.name=full_name.
2 change(s) for postgresql:
  1. drop column customers.name  [DESTRUCTIVE]
     ALTER TABLE customers DROP COLUMN name;
  2. add column customers.full_name
     ALTER TABLE customers ADD COLUMN full_name varchar(100) NOT NULL;
error[MXE108]: 1 destructive change(s) detected (marked [DESTRUCTIVE] above).
Review them, then run 'migrax generate --allow-destructive'. If a column or table was renamed, pass --rename table.old=new or --rename-table old=new instead.
</code></pre>
<p>It exits with code 1 and writes no file. Here, writing the plain drop and add would have been doubly wrong. It would have deleted both names. Then <code>ADD COLUMN ... NOT NULL</code> would have failed anyway, because the existing rows have no value for the new column.</p>
<p>The first line gives the option that keeps the data.</p>
<h3>In a terminal: Migrax asks</h3>
<p>Run the same command in a terminal and Migrax asks instead:</p>
<pre><code class="language-shell">$ migrax generate
Did you rename customers.name to customers.full_name? [y/N] y
Created src/main/resources/db/migration/0003_rename_customers_name.sql:
  - rename column customers.name to full_name
Rollback script written to src/main/resources/db/migration/rollback.
src/main/resources/db/migration/0003_rename_customers_name.sql, statement 1: warning MX008
  ALTER TABLE customers RENAME COLUMN name TO full_name
  Application instances still running the old code use the old name and will fail.
  Fix: Rename in steps: add the new name, write to both, switch reads, then remove the old name ('migrax generate --zero-downtime' does this). Or deploy during a maintenance window.
Review the SQL, then run 'migrax migrate'.
</code></pre>
<p>The default is "no". If you just press Enter, Migrax treats the change as a drop and an add, and refuses as in CI.</p>
<h3>The same answer as an option</h3>
<p>In a script, give the answer up front with <code>--rename table.old=new</code>:</p>
<pre><code class="language-shell">$ migrax generate --rename customers.name=full_name
Created src/main/resources/db/migration/0003_rename_customers_name.sql:
  - rename column customers.name to full_name
</code></pre>
<p>It writes exactly the same files as the "y" answer. Both runs above created byte-identical migrations. Separate several column renames with commas.</p>
<h3>The migration</h3>
<p><code>db/migration/0003_rename_customers_name.sql</code>:</p>
<pre><code class="language-sql">-- Generated by Migrax 0.17.1 for postgresql

ALTER TABLE customers RENAME COLUMN name TO full_name;
</code></pre>
<p><code>db/migration/rollback/0003_rename_customers_name.sql</code>:</p>
<pre><code class="language-sql">-- Generated by Migrax 0.17.1 for postgresql
-- Rollback for 0003_rename_customers_name.sql. 'migrax rollback' runs it.

ALTER TABLE customers RENAME COLUMN full_name TO name;
</code></pre>
<p>A rename moves no data, so it is fast even on a large table. Apply it, and the names are still there:</p>
<pre><code class="language-shell">$ migrax migrate
Applying migration '0003_rename_customers_name.sql'...
Successfully applied migration '0003_rename_customers_name.sql'.
Applied 1 migration(s); 3 total in src/main/resources/db/migration.
</code></pre>
<pre><code class="language-text"> id |  full_name   |      email
----+--------------+------------------
  1 | Ada Lovelace | ada@example.com
  2 | Alan Turing  | alan@example.com
</code></pre>
<h2>How Migrax spots a rename</h2>
<p>It treats a dropped column and an added column in the same table as a possible rename when:</p>
<ul>
<li>they are the same name in another style, like <code>created_at</code> and <code>createdAt</code> (even when the type changed too), or</li>
<li>the new column has the same type as the old one.</li>
</ul>
<p>A rename that also changes the type becomes a <code>RENAME</code> followed by an <code>ALTER</code>.</p>
<h2>Renaming a table</h2>
<p>Now rename the <code>orders</code> table:</p>
<pre><code class="language-java">@Entity
@Table(name = "purchase_orders")
public class PurchaseOrder {
</code></pre>
<p>A table rename has one more catch. Hibernate puts the table name into the names of the foreign keys it creates, so renaming the table changes the key's name too. Without an answer, Migrax again sees a drop and refuses:</p>
<pre><code class="language-shell">$ migrax generate
Possible rename: table orders to purchase_orders. To keep the data, rerun with --rename-table orders=purchase_orders.
4 change(s) for postgresql:
  1. drop foreign key fk_orders_customer_id_54d4cc52 on orders  [DESTRUCTIVE]
     ALTER TABLE orders DROP CONSTRAINT fk_orders_customer_id_54d4cc52;
  2. create table purchase_orders
     CREATE TABLE purchase_orders (createdAt timestamp(6) with time zone, customer_id bigint NOT NULL, id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, total numeric(12,2) NOT NULL, CONSTRAINT purchase_orders_pkey PRIMARY KEY (id));
  3. drop table orders  [DESTRUCTIVE]
     DROP TABLE orders;
  4. add foreign key fk_purchase_orders_cu_1ba47d9c on purchase_orders
     ALTER TABLE purchase_orders ADD CONSTRAINT fk_purchase_orders_cu_1ba47d9c FOREIGN KEY (customer_id) REFERENCES customers (id);
error[MXE108]: 2 destructive change(s) detected (marked [DESTRUCTIVE] above).
Review them, then run 'migrax generate --allow-destructive'. If a column or table was renamed, pass --rename table.old=new or --rename-table old=new instead.
</code></pre>
<p>Answer "y" in a terminal, or pass <code>--rename-table orders=purchase_orders</code> (both write the same files again):</p>
<pre><code class="language-shell">$ migrax generate
Did you rename table orders to purchase_orders? [y/N] y
Created src/main/resources/db/migration/0004_rename_orders.sql:
  - rename table orders to purchase_orders
  - rename foreign key fk_orders_customer_id_54d4cc52 to fk_purchase_orders_cu_1ba47d9c on purchase_orders
Rollback script written to src/main/resources/db/migration/rollback.
</code></pre>
<p>(The MX008 warning from before appears here too.)</p>
<p><code>db/migration/0004_rename_orders.sql</code>:</p>
<pre><code class="language-sql">-- Generated by Migrax 0.17.1 for postgresql

ALTER TABLE orders RENAME TO purchase_orders;
ALTER TABLE purchase_orders RENAME CONSTRAINT fk_orders_customer_id_54d4cc52 TO fk_purchase_orders_cu_1ba47d9c;
</code></pre>
<p>The foreign key keeps its columns and its target and only gets its new name. On PostgreSQL, CockroachDB, YugabyteDB and H2 that is a <code>RENAME CONSTRAINT</code>, which is instant. Other databases can't rename a foreign key, so there Migrax drops the key and adds it again under the new name. That makes the database check every row once. Before Migrax 0.17.1, a table rename needed <code>--allow-destructive</code> because of this key. Now the answer to the rename question is enough.</p>
<p>After <code>migrax migrate</code>, the orders still belong to the same customers:</p>
<pre><code class="language-text"> id |  full_name   | total
----+--------------+--------
  1 | Ada Lovelace |  49.90
  2 | Ada Lovelace | 120.00
  3 | Alan Turing  |  15.50
</code></pre>
<p><code>migrax drift</code> agrees that the database matches the entities:</p>
<pre><code class="language-shell">$ migrax drift
No drift: the database matches the snapshot.
</code></pre>
<h2>Undoing a rename</h2>
<p>The rollback script renames everything back, foreign key included:</p>
<p><code>db/migration/rollback/0004_rename_orders.sql</code>:</p>
<pre><code class="language-sql">ALTER TABLE purchase_orders RENAME CONSTRAINT fk_purchase_orders_cu_1ba47d9c TO fk_orders_customer_id_54d4cc52;
ALTER TABLE purchase_orders RENAME TO orders;
</code></pre>
<pre><code class="language-shell">$ migrax rollback --yes
Rolling back 1 migration(s), newest first:
  0004_rename_orders.sql
Rolling back migration '0004_rename_orders.sql'...
Rolled back migration '0004_rename_orders.sql'.
Rolled back 1 migration(s).
</code></pre>
<p>A rename moves no data, so undoing it loses none either. <code>migrax migrate</code> applies it again.</p>
<h2>A rename while the old code still runs</h2>
<p>The MX008 warning is about deployment, not data. During a rolling deploy, instances of the old version keep running for a while. They still query <code>customers.name</code> and fail as soon as the column is renamed. You have two options:</p>
<ul>
<li>deploy during a short maintenance window, so no old instance is left when the migration runs, or</li>
<li>rename in steps: add the new column, write to both, switch reads, then drop the old one. <code>migrax generate --zero-downtime</code> writes these steps for you (<a href="https://migrax.org/guides/zero-downtime/">Zero-downtime changes</a>).</li>
</ul>
<h2>Summary</h2>
<ul>
<li>Migrax spots likely renames and asks about them. It never drops data on a guess.</li>
<li>In CI it refuses with MXE108 and prints the exact <code>--rename</code> or <code>--rename-table</code> option to use.</li>
<li>A table rename also renames its foreign keys, in place where the database supports it.</li>
<li>Every rename has a rollback script that renames it back.</li>
</ul>
<p>More in the guide: <a href="https://migrax.org/guides/changing-entities/">Changing entities</a>.</p>
<hr />
<p><em>Originally published at</em> <a href="https://migrax.org/blog/renaming-columns-and-tables-without-losing-data/"><em>migrax.org</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Generate SQL migrations from your JPA entities]]></title><description><![CDATA[Your JPA entities already describe the schema you want. Writing the same change again by hand as SQL is slow and easy to get wrong. Migrax compares your…]]></description><link>https://fsmutimeer.hashnode.dev/generate-sql-migrations-from-your-jpa-entities</link><guid isPermaLink="true">https://fsmutimeer.hashnode.dev/generate-sql-migrations-from-your-jpa-entities</guid><category><![CDATA[Java]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[database]]></category><dc:creator><![CDATA[Feroz Shah]]></dc:creator><pubDate>Sun, 25 Oct 2026 16:33:34 GMT</pubDate><content:encoded><![CDATA[<p>Your JPA entities already describe the schema you want. Writing the same change again by hand as SQL is slow and easy to get wrong. Migrax compares your entities with the last state you committed and writes the SQL migration for you, along with a rollback script. You review the SQL, commit it, and apply it.</p>
<p>This article walks through that loop on a small project: create the first tables, add a column, and catch a missing migration in CI.</p>
<blockquote>
<p><strong>Tested with:</strong> Migrax 0.16.0, Hibernate 6.6.13, PostgreSQL 17. Every output below was copied from a real run.</p>
</blockquote>
<h2>The project</h2>
<p>Two entities, a customer and an order:</p>
<pre><code class="language-java">@Entity
@Table(name = "customers")
public class Customer {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  Long id;

  @Column(nullable = false, unique = true, length = 150)
  String email;

  @Column(nullable = false, length = 100)
  String name;
}

@Entity
@Table(name = "orders")
public class PurchaseOrder {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  Long id;

  @ManyToOne(optional = false)
  Customer customer;

  @Column(nullable = false, precision = 12, scale = 2)
  BigDecimal total;

  Instant createdAt;
}
</code></pre>
<p>Migrax needs to know where the database is. In <code>src/main/resources/application.properties</code>:</p>
<pre><code class="language-properties">migrax.url=jdbc:postgresql://localhost:5432/shop
migrax.user=shop
migrax.password=shop
</code></pre>
<p>A Spring Boot, Quarkus, Micronaut or Helidon project can skip this: Migrax reads the framework's own data source settings.</p>
<p><a href="https://migrax.org/getting-started/installation/">Install Migrax</a>, then run <code>migrax init</code> and <code>migrax doctor</code> in the project folder. <code>init</code> creates the migration folder; <code>doctor</code> checks that the project compiles, finds the entities and reaches the database.</p>
<h2>The first migration</h2>
<pre><code class="language-shell">$ migrax generate
No snapshot yet: using the current database schema as the baseline.
Created src/main/resources/db/migration/0001_initial.sql:
  - create table customers
  - create table orders
  - add foreign key fk_orders_customer_id_54d4cc52 on orders
Rollback script written to src/main/resources/db/migration/rollback.
Review the SQL, then run 'migrax migrate'.
</code></pre>
<p>Migrax reads the entities through Hibernate's own mapping, so the SQL matches what Hibernate expects, column types and constraint names included:</p>
<p><code>db/migration/0001_initial.sql</code>:</p>
<pre><code class="language-sql">-- Generated by Migrax 0.16.0 for postgresql

CREATE TABLE customers (email varchar(150) NOT NULL, id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, name varchar(100) NOT NULL, CONSTRAINT customers_pkey PRIMARY KEY (id), CONSTRAINT uk_customers_email UNIQUE (email));
CREATE TABLE orders (createdAt timestamp(6) with time zone, customer_id bigint NOT NULL, id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, total numeric(12,2) NOT NULL, CONSTRAINT orders_pkey PRIMARY KEY (id));
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer_id_54d4cc52 FOREIGN KEY (customer_id) REFERENCES customers (id);
</code></pre>
<p>Next to it is the rollback script. It says which data a rollback would delete:</p>
<p><code>db/migration/rollback/0001_initial.sql</code>:</p>
<pre><code class="language-sql">-- Generated by Migrax 0.16.0 for postgresql
-- Rollback for 0001_initial.sql. 'migrax rollback' runs it.
-- Note: deletes data written to customers since the migration ran.
-- Note: deletes data written to orders since the migration ran.

ALTER TABLE orders DROP CONSTRAINT fk_orders_customer_id_54d4cc52;
DROP TABLE customers;
DROP TABLE orders;
</code></pre>
<p>Both are plain SQL files. Read them, change them if you want, and apply them:</p>
<pre><code class="language-shell">$ migrax migrate
Applying migration '0001_initial.sql'...
Successfully applied migration '0001_initial.sql'.
Applied 1 migration(s); 1 total in src/main/resources/db/migration.
</code></pre>
<p>Migrax also wrote <code>.migrax/snapshot.json</code>: the schema your entities describe right now. Commit it with the migration files. The next <code>generate</code> compares against it, so it doesn't need a database to work out what changed.</p>
<h2>Adding a column</h2>
<p>Add a field to <code>Customer</code>:</p>
<pre><code class="language-java">  @Column(length = 30)
  String phone;
</code></pre>
<pre><code class="language-shell">$ migrax generate
Created src/main/resources/db/migration/0002_add_customers_phone.sql:
  - add column customers.phone
Rollback script written to src/main/resources/db/migration/rollback.
Review the SQL, then run 'migrax migrate'.
</code></pre>
<p><code>db/migration/0002_add_customers_phone.sql</code>:</p>
<pre><code class="language-sql">ALTER TABLE customers ADD COLUMN phone varchar(30);
</code></pre>
<p>The file name describes the change. The rollback script is <code>ALTER TABLE customers DROP COLUMN phone;</code>. Apply it with <code>migrax migrate</code>, as before.</p>
<h2>Keeping entities and migrations in sync</h2>
<p>Someone will eventually change an entity and forget to generate the migration. Say a teammate renames the field <code>name</code> to <code>fullName</code> (column <code>full_name</code>) and pushes only the Java change. <code>migrax check</code> catches that without a database, so it fits in CI:</p>
<pre><code class="language-shell">$ migrax check
2 entity change(s) have no migration:
  - drop column customers.name
  - add column customers.full_name
Run 'migrax generate' and commit the result.
</code></pre>
<p>It exits with code 2 when a migration is missing and 0 when everything matches:</p>
<pre><code class="language-shell">$ migrax check
OK: entities match the snapshot.
</code></pre>
<h2>Summary</h2>
<ul>
<li>Change an entity, run <code>migrax generate</code>, read the SQL, commit, run <code>migrax migrate</code>.</li>
<li>Every migration comes with a rollback script that says what data it would delete.</li>
<li><code>migrax check</code> in CI catches entity changes that have no migration.</li>
</ul>
<hr />
<p><em>Originally published at</em> <a href="https://migrax.org/blog/generate-sql-migrations-from-jpa-entities/"><em>migrax.org</em></a><em>.</em></p>
]]></content:encoded></item></channel></rss>