Skip to content
gohacks.dev
Databases

Ent Migrations With Atlas for Safe Schema Changes in Go

Generate reviewed SQL migration files from an Ent schema with Atlas: a no change diff writes nothing, one new field writes one ALTER TABLE, all files apply

10 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

Schema.Create is how you meet Ent and how you test it, and it is the wrong way to change a production database. It computes a diff against whatever it finds and applies it on the spot: no review, no record, no way to see or undo what ran. Ent's alternative is versioned migrations. The schema is still the source of truth, but the output is a directory of SQL files that go through code review and a deploy step, computed by Atlas, the migration engine Ent is built on. This article generates them for the author and book schema on PostgreSQL 18.6 and applies them to an empty database to prove they are complete.

Tested with Go 1.25.6, Ent v0.14.5, Atlas (ariga.io/atlas, library mode), lib/pq v1.10.9, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64). The Atlas CLI was not run.

It follows the Ent tutorial and the code generation workflow. The same argument for GORM is in AutoMigrate and its limits and goose or Atlas with GORM, and the zero downtime side is in zero downtime migrations.

How it works

Three pieces cooperate:

  1. The desired state: your Ent schema.
  2. The current state: the result of replaying the files already in the migrations directory.
  3. A dev database: a clean, disposable database Atlas uses to replay the directory and to normalise types. It must not contain your data.

Atlas replays the existing files into the dev database, inspects the result, compares it with the schema and writes the difference as a new SQL file named <version>_<name>.sql. It also writes atlas.sum, a checksum file that records the files and their order. Editing or reordering a committed migration breaks the checksum, and the check fails, which is how a team finds out that someone changed history.

The feature is called sql/versioned-migration and is enabled in the generate directive, because it adds the migrate.NamedDiff function to the generated migrate package:

ent/generate.goGo
package ent

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/versioned-migration ./schema

The migration program

A small program in the repository calls NamedDiff, with a second mode that applies the files. Real projects keep the first in cmd/migrate and run it from a Makefile target:

main.goGo
package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"ariga.io/atlas/sql/migrate"
	"entgo.io/ent/dialect"
	entmigrate "entgo.io/ent/dialect/sql/schema"
	emigrate "example.test/lab/ent/migrate"
	_ "github.com/lib/pq"
)

const dev = base + "&search_path=l0742_dev"

// diff computes the next migration file from the schema, using a scratch "dev" schema to replay the directory.
func diff(name string) {
	ctx := context.Background()
	admin, _ := sql.Open("pgx", base)
	admin.Exec("drop schema if exists l0742_dev cascade")
	admin.Exec("create schema l0742_dev")
	admin.Close()
	dir, err := migrate.NewLocalDir("migrations")
	if err != nil {
		log.Fatal(err)
	}
	err = emigrate.NamedDiff(ctx, dev, name,
		entmigrate.WithDir(dir),
		entmigrate.WithMigrationMode(entmigrate.ModeReplay),
		entmigrate.WithDialect(dialect.Postgres),
		entmigrate.WithFormatter(migrate.DefaultFormatter),
	)
	if err != nil {
		log.Fatal(err)
	}
}

// apply runs the migration files in order against a fresh schema, as a deploy step would.
func apply() {
	admin, _ := sql.Open("pgx", base)
	admin.Exec("drop schema if exists l0742_app cascade")
	admin.Exec("create schema l0742_app")
	admin.Close()
	db, err := sql.Open("pgx", base+"&search_path=l0742_app")
	if err != nil {
		log.Fatal(err)
	}
	files, _ := filepath.Glob("migrations/*.sql")
	sort.Strings(files)
	for _, f := range files {
		b, _ := os.ReadFile(f)
		if _, err := db.Exec(string(b)); err != nil {
			log.Fatal(f, ": ", err)
		}
		fmt.Println("applied", filepath.Base(f)[15:])
	}
	rows, _ := db.Query(`select column_name from information_schema.columns where table_schema = 'l0742_app' and table_name = 'books' order by ordinal_position`)
	var cols []string
	for rows.Next() {
		var c string
		rows.Scan(&c)
		cols = append(cols, c)
	}
	fmt.Println("books columns after applying:", strings.Join(cols, ", "))
}

func main() {
	log.SetFlags(0)
	if len(os.Args) > 1 && os.Args[1] == "apply" {
		apply()
		return
	}
	diff(os.Args[1])
}
db.goGo
package main

import (
	"database/sql"
	"log"

	"entgo.io/ent/dialect"
	entsql "entgo.io/ent/dialect/sql"
	"example.test/lab/ent"
	_ "github.com/jackc/pgx/v5/stdlib"
)

const base = "postgres://postgres:[email protected]:55432/lab?sslmode=disable"

// open recreates a schema and returns a client whose connections use it.
func open(schema string, opts ...ent.Option) *ent.Client {
	admin, err := sql.Open("pgx", base)
	if err != nil {
		log.Fatal(err)
	}
	admin.Exec("drop schema if exists " + schema + " cascade")
	admin.Exec("create schema " + schema)
	admin.Close()
	db, err := sql.Open("pgx", base+"&search_path="+schema)
	if err != nil {
		log.Fatal(err)
	}
	drv := entsql.OpenDB(dialect.Postgres, db)
	return ent.NewClient(append([]ent.Option{ent.Driver(drv)}, opts...)...)
}

func must(err error) {
	if err != nil {
		log.SetFlags(0)
		log.Fatal(err)
	}
}
ent/schema/author.goGo
package schema

import (
	"time"

	"entgo.io/ent"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
)

type Author struct{ ent.Schema }

func (Author) Fields() []ent.Field {
	return []ent.Field{
		field.String("name").NotEmpty(),
		field.String("email").Unique(),
		field.Time("created_at").Default(time.Now).Immutable(),
	}
}

func (Author) Edges() []ent.Edge { return []ent.Edge{edge.To("books", Book.Type)} }
ent/schema/book.goGo
package schema

import (
	"entgo.io/ent"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
	"entgo.io/ent/schema/index"
)

type Book struct{ ent.Schema }

func (Book) Fields() []ent.Field {
	return []ent.Field{
		field.String("title").NotEmpty(),
		field.Int("pages").Positive(),
		field.Bool("published").Default(false),
	}
}

func (Book) Edges() []ent.Edge {
	return []ent.Edge{edge.From("author", Author.Type).Ref("books").Unique().Required()}
}

func (Book) Indexes() []ent.Index { return []ent.Index{index.Fields("title").Edges("author").Unique()} }
Shell
set -e
rm -rf migrations && mkdir migrations
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
echo "== first diff: the whole schema"
go run . create_tables
cat migrations/*create_tables.sql
echo "== second diff, with no schema change"
go run . nothing_changed
ls migrations | sed -E 's/^[0-9]+/<version>/'
perl -0pi -e 's/(field\.Bool\("published"\)\.Default\(false\),)/$1\n\t\tfield.String("isbn").Optional(),/' ent/schema/book.go
GOFLAGS=-mod=mod go generate ./ent
echo "== third diff, after adding Book.isbn"
go run . add_isbn
ls migrations | sed -E 's/^[0-9]+/<version>/'
echo "== the new file"
cat migrations/*add_isbn.sql
echo "== apply all files to an empty schema"
go run . apply
Text
== first diff: the whole schema
-- Create "authors" table
CREATE TABLE "authors" ("id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "name" character varying NOT NULL, "email" character varying NOT NULL, "created_at" timestamptz NOT NULL, PRIMARY KEY ("id"));
-- Create index "authors_email_key" to table: "authors"
CREATE UNIQUE INDEX "authors_email_key" ON "authors" ("email");
-- Create "books" table
CREATE TABLE "books" ("id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "title" character varying NOT NULL, "pages" bigint NOT NULL, "published" boolean NOT NULL DEFAULT false, "author_books" bigint NOT NULL, PRIMARY KEY ("id"), CONSTRAINT "books_authors_books" FOREIGN KEY ("author_books") REFERENCES "authors" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION);
-- Create index "book_title_author_books" to table: "books"
CREATE UNIQUE INDEX "book_title_author_books" ON "books" ("title", "author_books");
== second diff, with no schema change
<version>_create_tables.sql
atlas.sum
== third diff, after adding Book.isbn
<version>_create_tables.sql
<version>_add_isbn.sql
atlas.sum
== the new file
-- Modify "books" table
ALTER TABLE "books" ADD COLUMN "isbn" character varying NULL;
== apply all files to an empty schema
applied create_tables.sql
applied add_isbn.sql
books columns after applying: id, title, pages, published, author_books, isbn

Details of diff:

  • The dev database is a scratch schema, dropped and recreated before every run so it is clean. Atlas needs a driver registered under the URL scheme, which is why the program imports lib/pq (it registers postgres) alongside the pgx driver the rest of the lab uses.
  • NewLocalDir("migrations") is the directory of SQL files.
  • WithMigrationMode(ModeReplay) tells Atlas to rebuild the current state by replaying the directory, which is the reliable way: the alternative reads state from a live database.
  • WithDialect(dialect.Postgres) picks the SQL flavour, and WithFormatter(migrate.DefaultFormatter) writes plain SQL files that the Atlas CLI and other tools understand.

What the run did

The first diff against an empty directory produced create_tables.sql with the complete schema. Each statement has a comment naming what it creates:

-- Create "authors" table, then CREATE TABLE "authors" ("id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, ... PRIMARY KEY ("id"));, CREATE UNIQUE INDEX "authors_email_key" ..., the books table with its foreign key books_authors_books, and CREATE UNIQUE INDEX "book_title_author_books". This is the SQL a reviewer reads. Unlike a printed WriteTo, it is now a file that is committed.

The second diff, with no schema change, wrote nothing. The directory listing afterwards is still <version>_create_tables.sql and atlas.sum. That is the property that makes a CI check possible: run the diff on every pull request and fail if it creates a file, because it means the schema changed without a migration.

The third diff, after adding an optional isbn field, wrote one small file:

-- Modify "books" table and ALTER TABLE "books" ADD COLUMN "isbn" character varying NULL;

A nullable column with no default is the safest possible ALTER on PostgreSQL: it is a catalogue change that does not rewrite the table. Atlas computed it from the difference between the replayed state and the new schema, and nothing else.

Applying the files in order to an empty schema applied create_tables.sql and add_isbn.sql, and the result had the expected columns: id, title, pages, published, author_books, isbn. The lab's apply is a stand-in for your deploy tool; in production you apply with the Atlas CLI, golang-migrate, goose or another runner that tracks which files have run (the goose version). I did not run the Atlas CLI here: the lab uses Atlas as a library through Ent, and Atlas's own commands (atlas migrate apply, atlas migrate lint) are described in its documentation.

Review what Atlas writes

Atlas computes the correct difference and does not know your traffic. Read each generated file with these questions:

  • Is it a table rewrite? Changing a column type, adding a column with a volatile default (on older PostgreSQL versions), and adding NOT NULL with a default can rewrite or lock a large table. Rewrite those as a staged change.
  • Does it take a long lock? CREATE INDEX without CONCURRENTLY blocks writes. Edit the file to use CREATE INDEX CONCURRENTLY (which cannot run in a transaction block, so it needs its own file).
  • Is a rename a drop and an add? A diff tool sees a renamed field as a removed column and an added column, and the data does not move. Write the ALTER TABLE ... RENAME COLUMN by hand.
  • Does a backfill belong here? A new NOT NULL column needs the staged sequence: add nullable, backfill in batches, then add the constraint.

Editing a generated file is allowed before it is committed. After editing, regenerate the checksum with atlas migrate hash, or the sum check will fail. Never edit a file that has already run anywhere.

Making it routine

  • A Makefile target for the diff: make migration name=add_isbn.
  • A CI job that regenerates the code and runs the diff, failing if either leaves a change.
  • A lint step (atlas migrate lint) that flags destructive changes, if you use the CLI.
  • An apply step in the deploy pipeline that runs before the new version starts, using the same files that were reviewed.
  • The dev database as a service container in CI, always empty.

Ent's own Schema.Create stays useful in tests, where a fresh in memory or per test schema is created from the schema each time (Ent testing). Do not run it against shared databases.

Checklist

  • Enable sql/versioned-migration and call NamedDiff from a small program.
  • Use a clean, disposable dev database.
  • Commit the SQL files and atlas.sum.
  • Run the diff in CI and fail if it writes a file.
  • Review every file for locks, rewrites and renames before committing.
  • Apply the reviewed files with a tracking runner, never Schema.Create.

The workflow is documented in the Ent versioned migrations guide and the Atlas documentation.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.