Schema file from your ORM¶
New in Migrax 0.17.0.
With a schema file,
Migrax writes your migrations from the tables you describe in SQL. If your project already
describes its tables in an ORM, you don't have to write that SQL by hand: most ORMs can print
their models as CREATE TABLE statements. Export them into the schema file, and the loop is:
- Change a model.
- Export the schema file (one command).
- Run
migrax generate, review the migration, commit it, runmigrax migrate.
Tested with
Migrax 0.17.0, PostgreSQL 17 and MySQL 8.4, SQLAlchemy 2.1.5 (Python 3.13), Drizzle ORM 0.45.4 with drizzle-kit 0.31.11 (Node.js 24), GORM 1.31.2 (Go 1.25). Every output below was copied from a real run.
The settings are the ones from SQL migrations for any language, plus the schema file:
export MIGRAX_DATABASE_URL=jdbc:postgresql://localhost:5432/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
SQLAlchemy (Python)¶
The models:
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")
SQLAlchemy prints a table's CREATE TABLE for a given database without connecting to one.
A small script writes all of them:
"""Prints the tables of models.py as SQL, for migrax.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")
For MySQL, import mysql from sqlalchemy.dialects and use mysql.dialect().
$ python export_schema.py > 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'.
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);
SQLAlchemy writes the id columns as SERIAL; Migrax writes them as identity columns, which
hand out ids the same way. After migrax migrate, SQLAlchemy saves and loads rows as before.
Now add a field to Customer:
Export again; migrax check sees the change, and generate writes it:
$ python export_schema.py > db/schema.sql
$ migrax check
1 change(s) in db/schema.sql have no migration:
- add column customer.phone
Run 'migrax generate' and commit the result.
$ 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'.
Drizzle (TypeScript)¶
The schema:
import { bigint, index, numeric, pgTable, timestamp, varchar } from "drizzle-orm/pg-core";
export const customer = pgTable("customer", {
id: bigint("id", { mode: "number" }).primaryKey().generatedByDefaultAsIdentity(),
email: varchar("email", { length: 150 }).notNull().unique(),
name: varchar("name", { length: 100 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const orders = pgTable("orders", {
id: bigint("id", { mode: "number" }).primaryKey().generatedByDefaultAsIdentity(),
customerId: bigint("customer_id", { mode: "number" }).notNull()
.references(() => customer.id),
total: numeric("total", { precision: 12, scale: 2 }).notNull(),
status: varchar("status", { length: 20 }).notNull().default("new"),
}, (table) => [index("orders_customer").on(table.customerId)]);
drizzle-kit export prints the schema as SQL, without a database:
$ npx drizzle-kit export --dialect=postgresql --schema=./schema.ts > 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'.
CREATE TABLE customer (id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, email varchar(150) NOT NULL, name varchar(100) NOT NULL, created_at timestamp(6) with time zone DEFAULT now() NOT NULL, CONSTRAINT customer_pkey PRIMARY KEY (id), CONSTRAINT uk_customer_email UNIQUE (email));
CREATE TABLE orders (id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, customer_id bigint NOT NULL, total numeric(12,2) NOT NULL, status varchar(20) DEFAULT 'new' NOT NULL, CONSTRAINT orders_pkey PRIMARY KEY (id));
CREATE INDEX orders_customer ON orders (customer_id);
ALTER TABLE orders ADD CONSTRAINT orders_customer_id_customer_id_fk FOREIGN KEY (customer_id) REFERENCES customer (id);
Drizzle names the unique constraint on email customer_email_unique; Migrax names it
uk_customer_email. The constraint is the same. After migrax migrate, Drizzle inserts and
reads rows as before. Adding phone: varchar("phone", { length: 30 }) to customer and
exporting again gives:
Go, Rust and any other ORM¶
Some ORMs can't print their models as SQL, but all of them can create their tables, for
example GORM's AutoMigrate. Let the ORM create them in a database of its own for this (not
the one Migrax migrates), and copy the tables from there with pg_dump or mysqldump.
The models, in Go with GORM:
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'"`
}
A small program runs db.AutoMigrate(&Customer{}, &Order{}) against an empty database
shop_dev. Then:
$ pg_dump --schema-only --no-owner --no-privileges shop_dev > 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'.
CREATE TABLE customers (id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, email varchar(150) NOT NULL, name varchar(100) NOT NULL, created_at timestamp(6) with time zone DEFAULT now() NOT NULL, CONSTRAINT customers_pkey PRIMARY KEY (id), CONSTRAINT uk_customers_email UNIQUE (email));
CREATE TABLE orders (id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, customer_id bigint NOT NULL, total numeric(12,2) NOT NULL, status varchar(20) DEFAULT 'new' NOT NULL, CONSTRAINT orders_pkey PRIMARY KEY (id));
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id);
After migrax migrate, GORM saves and loads customers and orders in the migrated database.
Adding Phone *string with gorm:"size:30" to Customer, running AutoMigrate on
shop_dev again and dumping it gives 0002_add_customers_phone.sql with
ALTER TABLE customers ADD COLUMN phone varchar(30);.
What to know about dumps:
- Use
--no-owner --no-privileges: without them, the file names the roles of your server, which the throwaway database doesn't have. - Migrax runs the throwaway database in your server's version, because a dump uses that
version's settings. Pass
--image(or setmigrax.scratch-image) to choose another image. - The
\restrictlines that newerpg_dumpversions write are psql commands, not SQL: Migrax skips them. Their random key changes on every dump, but the cached model in.migrax/still counts the file as unchanged. - On MySQL and MariaDB,
mysqldump --no-data --skip-comments shop_dev > db/schema.sqlworks the same way. Unsigned ids (bigint unsigned, GORM'suint) stay unsigned in the migration.
Any ORM works the same way if it can print its models as CREATE TABLE statements or create
its tables in a database you can dump. Read the migration Migrax writes before you commit it.
What the file can contain lists
what Migrax reads from it.
Make it one step¶
Put the export and the check in your build or CI, so a model change without a migration is caught:
python export_schema.py > db/schema.sql # or: npx drizzle-kit export ..., pg_dump ...
migrax check # exit code 2: a change has no migration yet
Commit db/schema.sql and .migrax/ with each migration: while the file is unchanged,
migrax check needs no Docker (details).