Migrate From GORM to Ent One Package at a Time Safely
Move a Go service from GORM to Ent without a rewrite: map Ent onto GORM's tables, share one pool, read the schema diff and align index and foreign key names

Moving from GORM to Ent is a bigger step than moving to sqlc, because both own the description of your schema in Go. It does not have to be a rewrite. The two can sit side by side in one service, on one connection pool, reading and writing the same tables, and you can move the code over one package at a time. The one rule that keeps it safe is that only one of them may run migrations. This article does the move on PostgreSQL 18.6, with the commands that tell you whether Ent agrees with the schema GORM built, including one error and one drift report from real runs.
Tested with Go 1.25.6, GORM v1.31.2, Ent v0.14.5, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It is the Ent counterpart of migrating from GORM to sqlc, and it assumes you know both libraries: the GORM tutorial and the Ent tutorial. Before starting, read Ent vs GORM to check that the move is worth it.
The plan
- Put a repository interface in front of the GORM code, so callers do not import GORM.
- Map Ent schemas onto the existing tables, with matching column and constraint names.
- Share one
*sql.DBbetween the two. - Verify Ent agrees with the schema by printing its migration diff, never by running it.
- Move repositories to Ent one at a time, behind the interface and a flag.
- When GORM has no callers left, remove it. Move schema ownership to versioned migrations at any point along the way.
The interface step is the same as in the sqlc article and pays for itself even if you stop halfway. Everything below is about steps 2 to 4, where Ent is different.
The existing GORM models
The service today has two models and the tables GORM created for them:
package main
import "time"
// The GORM models the service has today.
type GormUser struct {
ID uint
Email string `gorm:"uniqueIndex"`
Name string
CreatedAt time.Time
UpdatedAt time.Time
Posts []GormPost `gorm:"foreignKey:UserID"`
}
func (GormUser) TableName() string { return "users" }
type GormPost struct {
ID uint
UserID uint
Title string
Body string
CreatedAt time.Time
}
func (GormPost) TableName() string { return "posts" }Tables users and posts, a unique index on the email, and a foreign key from posts to users, all made by AutoMigrate.
Mapping Ent onto them
An Ent schema normally creates its own table. To adopt an existing table, annotate it with the name and describe every column that exists:
package schema
import (
"time"
"entgo.io/ent"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
)
// User maps onto the table GORM already created.
type User struct{ ent.Schema }
func (User) Annotations() []schema.Annotation {
return []schema.Annotation{entsql.Annotation{Table: "users"}}
}
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("email").Unique(),
field.String("name"),
field.Time("created_at").Default(time.Now),
field.Time("updated_at").Default(time.Now).UpdateDefault(time.Now),
}
}
func (User) Edges() []ent.Edge { return []ent.Edge{edge.To("posts", Post.Type)} }package schema
import (
"time"
"entgo.io/ent"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
)
type Post struct{ ent.Schema }
func (Post) Annotations() []schema.Annotation {
return []schema.Annotation{entsql.Annotation{Table: "posts"}}
}
func (Post) Fields() []ent.Field {
return []ent.Field{
field.Int("user_id"),
field.String("title"),
field.Text("body").Default(""),
field.Time("created_at").Default(time.Now),
}
}
func (Post) Edges() []ent.Edge {
return []ent.Edge{edge.From("user", User.Type).Ref("posts").Unique().Required().Field("user_id")}
}The points that make the mapping work:
entsql.Annotation{Table: "users"}points the entity at the existing table.- Column names follow Ent's snake case, which matches GORM's:
created_at,updated_at,user_id. - The foreign key is a named field (
user_id) withField("user_id")on the edge, so the column Ent uses is the one GORM made. Default(time.Now)andUpdateDefault(time.Now)reproduce GORM's automatic timestamps in Go, so rows written by Ent have them, as the run shows.
The program: two libraries, one database
package main
import (
"bytes"
"context"
"fmt"
"log"
"os"
"strings"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/schema"
"example.test/lab/ent"
"example.test/lab/ent/user"
_ "github.com/jackc/pgx/v5/stdlib"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
log.SetFlags(0)
ctx := context.Background()
base := "host=127.0.0.1 port=55432 user=postgres password=secret dbname=lab sslmode=disable"
// 1. The existing system: GORM owns the tables and has written data.
admin, _ := gorm.Open(postgres.Open(base), &gorm.Config{Logger: logger.Discard})
admin.Exec("drop schema if exists l0754 cascade")
admin.Exec("create schema l0754")
g, err := gorm.Open(postgres.Open(base+" search_path=l0754"), &gorm.Config{Logger: logger.Discard})
if err != nil {
log.Fatal(err)
}
if err := g.AutoMigrate(&GormUser{}, &GormPost{}); err != nil {
log.Fatal(err)
}
ada := GormUser{Email: "[email protected]", Name: "Ada"}
g.Create(&ada)
g.Create(&GormPost{UserID: ada.ID, Title: "Written by GORM", Body: "hello"})
fmt.Println("== 1. GORM wrote one user and one post")
// 2. Ent on the same database, same tables.
sqlDB, _ := g.DB()
client := ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, sqlDB)))
u := client.User.Query().Where(user.Email("[email protected]")).WithPosts().OnlyX(ctx)
fmt.Println("== 2. Ent reads GORM's rows:", u.Name, "has", len(u.Edges.Posts), "post:", u.Edges.Posts[0].Title)
bob := client.User.Create().SetEmail("[email protected]").SetName("Bob").SaveX(ctx)
client.Post.Create().SetUserID(bob.ID).SetTitle("Written by Ent").SaveX(ctx)
var back GormUser
g.Preload("Posts").Where("email = ?", "[email protected]").Take(&back)
fmt.Println(" GORM reads Ent's rows:", back.Name, "has", len(back.Posts), "post:", back.Posts[0].Title, "| created_at set:", !back.CreatedAt.IsZero())
// 3. What would Ent's own migration change? Never run Schema.Create against a table you do not own;
// WriteTo prints the difference without applying it.
var buf bytes.Buffer
if err := client.Schema.WriteTo(ctx, &buf, schema.WithSkipChanges(schema.ModifyColumn)); err != nil {
log.Fatal(err)
}
stmts := 0
fmt.Println("== 3. statements Ent would run against GORM's schema,", os.Getenv("SCHEMA_STATE")+":")
for _, l := range strings.Split(buf.String(), ";") {
if l = strings.TrimSpace(l); l != "" {
stmts++
fmt.Println(" ", l)
}
}
if stmts == 0 {
fmt.Println(" none: the Ent schema matches the tables")
}
g.Migrator().DropTable(&GormPost{}, &GormUser{})
}package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schemapackage schema
import (
"time"
"entgo.io/ent"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
)
// User maps onto the table GORM already created.
type User struct{ ent.Schema }
func (User) Annotations() []schema.Annotation {
return []schema.Annotation{entsql.Annotation{Table: "users"}}
}
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("email"),
field.String("name"),
field.Time("created_at").Default(time.Now),
field.Time("updated_at").Default(time.Now).UpdateDefault(time.Now),
}
}
func (User) Edges() []ent.Edge {
return []ent.Edge{edge.To("posts", Post.Type).StorageKey(edge.Symbol("fk_users_posts"))}
}
// Name the index the way GORM named it, so the two tools agree on the schema.
func (User) Indexes() []ent.Index {
return []ent.Index{index.Fields("email").Unique().StorageKey("idx_users_email")}
}set -e
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
SCHEMA_STATE="Ent schema as first written" go run .
cp aligned_user.txt ent/schema/user.go
GOFLAGS=-mod=mod go generate ./ent
SCHEMA_STATE="after naming the index and the foreign key" go run . | tail -2== 1. GORM wrote one user and one post
== 2. Ent reads GORM's rows: Ada has 1 post: Written by GORM
GORM reads Ent's rows: Bob has 1 post: Written by Ent | created_at set: true
== 3. statements Ent would run against GORM's schema, Ent schema as first written:
DROP INDEX "idx_users_email"
CREATE UNIQUE INDEX "users_email_key" ON "users" ("email")
ALTER TABLE "posts" DROP CONSTRAINT "fk_users_posts", ADD CONSTRAINT "posts_users_posts" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE NO ACTION
== 3. statements Ent would run against GORM's schema, after naming the index and the foreign key:
none: the Ent schema matches the tablesOne pool. The program gets sqlDB, _ := g.DB() from GORM and gives it to Ent through entsql.OpenDB. Both libraries now draw from a single pool with a single set of limits (pool settings). If you open a second pool for Ent, you double the connections and make transactions that span both libraries impossible.
Interop works. Section 2 of the output shows the property that makes the migration gradual. Ent read GORM's rows: Ada has 1 post: Written by GORM. Ent wrote Bob and his post, and GORM read them back: Bob has 1 post: Written by Ent | created_at set: true. The timestamp is set because the Ent default filled it. Both directions work because they share tables, so a service can run both for as long as the migration takes, and a rollback is switching the repository back.
Do not let Ent migrate GORM's tables
client.Schema.Create(ctx) would try to bring the database in line with the Ent schema: change indexes, constraints and maybe columns. On tables GORM owns, that is a risk, and on a production database it is an outage waiting. Instead, ask Ent what it would change, and read the answer. client.Schema.WriteTo(ctx, w) compares the schema with the live database and writes the SQL it would run without running it.
The first attempt at this in the lab failed:
sql/schema: reverse alter table "users": unexpected attribute change (expect IDENTITY)
GORM creates ids as bigserial columns, which are bigint with a sequence default. Ent on PostgreSQL expects identity columns (GENERATED BY DEFAULT AS IDENTITY) and tried to describe the change from one to the other. The fix for a read-only comparison is to skip column modifications: WriteTo(ctx, w, schema.WithSkipChanges(schema.ModifyColumn)). With that option the diff ran. It is a diff for review, not a plan for execution, and this is another reason not to apply it.
Do not convert GORM's bigserial ids to identity columns as part of this migration. Both work, the sequence is the same kind of object, and changing a primary key's definition is the sort of change that should not ride along with a library swap.
Reading the diff
With the Ent schema as first written, the diff was three statements:
DROP INDEX "idx_users_email"followed byCREATE UNIQUE INDEX "users_email_key" ON "users" ("email"): GORM named the unique indexidx_users_email, and Ent's unique field createsusers_email_key. Same index, different name.ALTER TABLE "posts" DROP CONSTRAINT "fk_users_posts", ADD CONSTRAINT "posts_users_posts" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE NO ACTION: the same foreign key under a different name.
All three are renames. They are harmless and still undesirable: if anyone ran Ent's migration, it would drop and recreate an index on a live table, taking a lock for the duration. The goal is a diff of none, which says that the Ent schema describes the database exactly and a future Schema.Create or Atlas diff would change nothing.
Aligning the names
The schema is edited, not the database. For the index, drop Unique() from the field and declare the index with a storage key:
index.Fields("email").Unique().StorageKey("idx_users_email")
For the foreign key, name the symbol on the edge: edge.To("posts", Post.Type).StorageKey(edge.Symbol("fk_users_posts")). The second half of the script copies the aligned schema over, regenerates and runs again. The report:
after naming the index and the foreign key: followed by none: the Ent schema matches the tables.
Doing this once per table is the work of the migration. Run the diff after each entity you map, and do not move on until it says none. A diff with real column differences (a type, a nullability, a default) is a decision: change the Ent schema to match the database, or write a migration to change the database, and the second one should be a reviewed SQL file, not a side effect of the new library.
Moving the code
Each repository is migrated like the sqlc case: implement the same interface over Ent, run the same contract tests against both (the contract test pattern), and switch with a flag. Things that change meaning in the move, to check in each contract:
- Hooks and defaults. GORM's
BeforeCreatehooks and zero value defaults do not carry over. Ent has hooks and field defaults of its own; port the behaviour deliberately. - Soft deletes. GORM's
DeletedAtfilter is automatic. In Ent it is a mixin with an interceptor (Ent mixins). Without it, deleted rows reappear in lists. - Nested creates. GORM saves associations implicitly. Ent saves what you build. Port the tag resolution logic (associations) as explicit steps.
- Errors. Replace
ErrRecordNotFoundandErrDuplicatedKeywithent.IsNotFoundandent.IsConstraintErrorinside the repository. - Transactions.
db.Transactionbecomesclient.Tx. A unit of work that spans both libraries during the migration needs one shared connection, which is possible by passing the same*sql.Txto both clients but is delicate; migrate whole transactions at a time instead.
Ending the migration
When the last GORM repository is gone, remove the models, the dependency and AutoMigrate. Make migrations versioned (Ent with Atlas), and keep the diff check in CI: Atlas or WriteTo against a migrated database should always say none.
Checklist
- Put an interface in front of GORM before anything else.
- Map Ent onto existing tables with annotations, and share one
*sql.DB. - Never run Ent's migration against tables GORM owns. Print the diff with
WriteTo. - Align index and constraint names until the diff is empty.
- Migrate one repository at a time, with a contract test and a flag.
- Port hooks, defaults, soft deletes and error handling explicitly.


