Skip to content

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:

  1. Change a model.
  2. Export the schema file (one command).
  3. Run migrax generate, review the migration, commit it, run migrax 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:

models.py
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:

export_schema.py
"""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'.
db/migrations/0001_initial.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);

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:

    phone: Mapped[str | None] = mapped_column(String(30))

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'.
db/migrations/0002_add_customer_phone.sql
ALTER TABLE customer ADD COLUMN phone varchar(30);

Drizzle (TypeScript)

The schema:

schema.ts
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'.
db/migrations/0001_initial.sql
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:

db/migrations/0002_add_customer_phone.sql
ALTER TABLE customer ADD COLUMN phone varchar(30);

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:

models.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'"`
}

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'.
db/migrations/0001_initial.sql
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 set migrax.scratch-image) to choose another image.
  • The \restrict lines that newer pg_dump versions 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.sql works the same way. Unsigned ids (bigint unsigned, GORM's uint) 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).