Why Go Needs Another ORM (And Why I Built One)

How do you manage your database schema in Go? GORM has auto-migrate, ent requires code generation, and third-party tools like goose feel bolted on. This is the story of why I built Neat ORM — a Laravel-grade ORM for Go with migrations, schema builder, soft deletes, associations, and 7 database drivers.

Why Go Needs Another ORM (And Why I Built One)

Why Go Needs Another ORM

I want to talk about something that confused me when I first started writing Go: how do you manage your database schema?

In most languages, this is a solved problem. In Laravel (PHP), you write migrations. In Django (Python), you run makemigrations and migrate. In Rails (Ruby), you run rails db:migrate. The framework handles it. You get versioned files, a tracking table, and the ability to roll back.

In Go, the answer is... it depends. And "it depends" usually means "you figure it out."


What Go Gives You

Go gives you database/sql. This is a perfectly fine interface for talking to databases. You open a connection, you write queries, you scan results into structs. It's clean and honest.

But database/sql doesn't know anything about your schema. It doesn't know what tables exist. It doesn't know what columns they have. It doesn't know how to create them, modify them, or roll back changes.

That's not a criticism. database/sql is a low-level library. It's not supposed to do those things. But it means you need something above it.


What the Go Ecosystem Gives You

Here's a quick tour of the Go ORM landscape:

GORM is the most popular. It's a full ORM with models, relationships, and a query builder. But its migration story is db.AutoMigrate(&User{}), which looks at your struct and adds any missing columns. There are no migration files. There's no rollback. There's no tracking table. It's "add columns and hope for the best."

ent (from Facebook) generates code from schemas. It's powerful but heavy. You write schemas in a DSL, run code generation, and get a whole API. Its migration support is either auto-migration (same problem as GORM) or versioned migrations that require a third-party tool.

sqlx is database/sql with struct scanning. Great, but it's not an ORM.

squirrel is a query builder. Also great, also not an ORM.

The pattern here is clear: Go has query builders, and Go has ORMs, but nobody has built a complete database management system — the kind where migrations, schema changes, seeders, and factories all work together.


What Other Languages Give You

Let me show you what Laravel gives you, because it's the clearest example.

When you want to create a table in Laravel, you write a migration:

Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('email')->unique();
    $table->timestamps();
});

When you run php artisan migrate, Laravel:

  1. Creates the users table.
  2. Records the migration in a migrations tracking table.
  3. Never runs it again.

When you want to roll back, you run php artisan migrate:rollback, and Laravel runs the down() method of the last migration batch.

This is not exotic. This is not over-engineered. This is basic database management that every major web framework has had for over a decade. Except in Go.


What I Built

I built Neat ORM. It's a Laravel-grade ORM for Go that includes everything I was missing.

Here's what a migration looks like in Neat:

type CreateUsersTable struct {
    migrator.BaseMigration
}

func (m *CreateUsersTable) Signature() string {
    return "2024_06_15_120000_create_users_table"
}

func (m *CreateUsersTable) Up() error {
    return m.GetSchema().Create("users", func(blueprint neat.Blueprint) {
        blueprint.ID()
        blueprint.String("name")
        blueprint.String("email").Unique()
        blueprint.Timestamps()
    })
}

func (m *CreateUsersTable) Down() error {
    return m.GetSchema().DropIfExists("users")
}

And here's how you run it:

migrator := migrator.NewMigrator(db)
migrator.AddMigration(&CreateUsersTable{})
migrator.Up(context.Background())

That's it. The migrator creates a migration_tracker table automatically, records each migration, and never runs it twice. You can roll back with migrator.Down(ctx) or migrator.RollbackSteps(ctx, 3).


But It's More Than Migrations

Migrations were the thing that drove me to build Neat, but they're not the only thing. Here's the full feature list:

  • Query builder — fluent, intuitive, with Where, OrWhere, OrderBy, Limit, joins, aggregations
  • Schema builder — Blueprint pattern, create tables in Go instead of SQL
  • Migrations — versioned, tracked, rollback, batch numbering, fresh, reset
  • Seeders — for test and initial data
  • Factories — for generating test records
  • Soft deletes — three strategies (nullable column, Laravel-compatible deleted_at, and max-date sentinel for NOT NULL constraints)
  • Observers — lifecycle events (Creating, Created, Updating, Deleting, etc.)
  • Associations — BelongsTo, HasMany, HasOne, polymorphic, with eager and lazy loading
  • 7 database drivers — MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Turso, Oracle
  • Connection pooling — with sensible defaults per driver (SQLite gets MaxOpen=1 to prevent writer contention)
  • Security — identifier quoting, error sanitization, DSN redaction

I also added method aliases from other frameworks. Django's Filter, Exclude, All. Sequelize's FindAll, FindOne, Destroy. The idea was to take the best ideas from across the ecosystem — Laravel's migrations, Django's query API, Sequelize's naming — and bring them together in something that feels native to Go.


Why Not Just Contribute to GORM?

People will ask this, so let me answer it.

GORM is built on top of database/sql in a way that makes certain things hard. Its auto-migration approach — db.AutoMigrate(&User{}) — is fundamentally different from a real migration system. A real migration system has versioned files, rollback support, and a tracking table. GORM's approach is "add any missing columns and hope nothing breaks."

Adding a real migration system to GORM would mean rebuilding its foundation. At that point, you might as well start from scratch.

Which is what I did.


Is It Actually Used?

Yes! Neat ORM runs in production across multiple websites. It powers CMS, blogs, user management, and more.

Every datastore in the dracory ecosystem depends on Neat for database operations.

It has 50+ source files, 16 example projects, Docker Compose integration tests, CI/CD with GitHub Actions and Codecov, and HTML documentation.


The Meta Part

Here's something I find interesting: Neat ORM was mainly written with AI assistance. Google's Jules bot implemented the in-memory array driver. A combination of AI models — Kimi, GLM, Claude, and Gemini — handled documentation and code updates. An ORM that helps build AI-powered applications was itself mainly written by AI.

I think this is going to become more common. Not just "AI wrote my code" but "AI wrote most of the code, and I kept it on the right path."


Where to Find It

The code is at github.com/dracory/neat. The README has a quick start guide, configuration examples, and links to 16 example projects covering everything from basic CRUD to polymorphic associations.

If you've been missing Laravel-grade database tooling in Go, this is for you.

Previous
From Golang to Static: Why I Switched to Netlify and AI-First Maintenance
Next
The Migration System — Neat ORM's Killer Feature

Keep Reading

Explore more articles and insights

Back to Golang: Why I Switched From Static to Dynamic Again

Back to Golang: Why I Switched From Static to Dynamic Again

Read Article
Associations and Eager Loading in Go — Without GORM

Associations and Eager Loading in Go — Without GORM

Read Article
Soft Deletes Done Right — Three Strategies for Go

Soft Deletes Done Right — Three Strategies for Go

Read Article
View All Blog Posts