Goose
-
Geni vs. Goose for lightweight database migrations
I have a pretty simple rule for database migrations: the tool lets me write SQL, check status, apply the next change, and roll back when I need to. I don’t want much more standing in the way.
That’s why I like both Geni and Goose.
Neither one is trying to be a full application framework. They don’t require the rest of the app to be written in the same language as the migration tool. They’re small enough to understand, script, and run from CI, but they have enough features that they don’t feel like a pile of shell scripts once a project grows real production databases.
The catch is that they’re good at slightly different jobs. I’ve ended up using both in the same migration repo: Goose for PostgreSQL, MySQL, and MariaDB, Geni for LibSQL/Turso. That split has worked well enough that I’d reach for the same shape again, even on projects that aren’t written in Go or Rust.
The short version
Use Goose when you want a mature, widely used SQL migration tool for the traditional server databases: PostgreSQL, MySQL, MariaDB, and local SQLite. Broad database matrix, familiar single-file format, status and rollback commands, environment-variable substitution, Go migrations when you need them, and a very plain CLI.
Use Geni when LibSQL or Turso is part of the system. It’s a standalone tool with first-class LibSQL/Turso support, a simple paired-file model, status/up/down/new commands, and optional schema snapshots. It was built for the space where “SQLite-like, but remote and edge-hosted” starts to expose the rough edges of tools designed around local SQLite files.
Reach for something heavier when migrations stop being ordered SQL files and turn into a schema-management problem. More on that at the end.
Why I like both
The best thing about Geni and Goose is that the migration stays close to the database.
There’s no ceremony around a model layer. You write SQL. You put it in version control. You run a CLI. The database gets a tracking table. A teammate can review the migration in a pull request without learning your ORM. A deploy script can ask for status before it applies anything. For a lot of small and medium projects, that’s exactly the right amount of machinery.
It also makes both tools language-independent in practice. Goose is written in Go, Geni in Rust, but a TypeScript, Python, Ruby, or PHP app can use either one just fine. The migration tool runs at the edge of the application. It talks to the database and the filesystem. It doesn’t need to be imported by the app.
That separation is underrated. A migration directory can outlive a framework rewrite. It can be shared by several services, live in an infrastructure repo, and get run by CI, a deploy script, or a human with a terminal.
Where Goose shines
Goose is the boring choice, in the best sense. Its migration format is easy to read:
-- +goose Up -- +goose StatementBegin CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE ); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP TABLE users; -- +goose StatementEndThe forward and rollback changes live in one file, and a review comment can talk about both in one place.
Goose is also strong when a repo has several conventional engines. It documents support for PostgreSQL, MySQL, MariaDB, SQLite, and more, and the command shape stays mostly the same across them:
goose -dir migrations/postgres/app/schema postgres "$POSTGRES_APP_DSN" up goose -dir migrations/mysql/app/schema mysql "$MYSQL_APP_DSN" upFor Go services there’s another useful escape hatch: Go migrations. Most schema changes should stay in SQL, but occasionally one needs batching, custom validation, or a data rewrite that’s clearer in code. Goose supports that without forcing every migration to become code.
I reach for Goose first on Postgres/MySQL/MariaDB, on teams that already know SQL migrations, and in deploy scripts that just want
status,up,down, andversion.Where Geni shines
Geni has a similar direct feel, but its file model is different:
migrations/ 1782054288_create_users_table.up.sql 1782054288_create_users_table.down.sqlForward migration in one file, rollback in another, both plain SQL. Geni can create the pair for you:
geni new create_users_table geni status geni upThe more important point is LibSQL/Turso. Turso is not just “a local SQLite file with a different name.” LibSQL is SQLite-compatible, but a remote Turso database brings its own connection shape, auth token, protocol, and operational behavior. That’s exactly where a migration tool either feels native or feels like you’re pushing it through a small opening.
Geni expects the Turso shape. The documented flow uses
DATABASE_URLandDATABASE_TOKEN, it speaks LibSQL, and it can dump aschema.sqlsnapshot after migrations, which is handy as a review artifact and source-control reference.On my own Turso projects I like a thin wrapper around Geni. It resolves secrets from whatever store the project uses, splits a composed DSN into
DATABASE_URLandDATABASE_TOKEN, points Geni at the right per-database folder, and masks tokens in debug output. That keeps the human command simple:mise run geni dev app status mise run geni prod app upOne folder per logical database, paired up/down files, secrets outside the repo, a status check before deploy, and a confirmation prompt before production
upordown.About Goose and Turso
This is the one place the recommendation needs nuance. Goose has been growing, and its current docs list a Turso driver. So the answer is no longer “Goose can’t talk to Turso.”
But “there’s a driver” and “this is the smoothest operational path for my Turso migrations” are not the same claim. The friction I care about isn’t just opening a connection. It’s the whole lifecycle: creating the tracking table, applying safely, transaction behavior, the right remote protocol, auth, status, and a workflow I trust in a short-lived feature branch and in production. For that job I still prefer Geni for LibSQL/Turso, and Goose stays my default for Postgres/MySQL/MariaDB.
When to use something heavier
Lightweight SQL migration tools aren’t always enough. I’d start looking at Atlas, Liquibase, Flyway, Sqitch, or an ORM-native system when the problem changes shape:
- You need schema drift detection across many environments.
- You want to declare desired schema and have the tool compute the migration.
- You need online migration planning for large tables.
- You operate hundreds or thousands of tenant databases.
- You need policy checks, approval workflows, or compliance artifacts before a migration can merge.
Those are real needs. They’re also real costs. The heavier tool might be worth it, but I wouldn’t start there by default.
For most projects, the better starting point is still: write a small SQL migration, review it in git, run
status, apply it, and keep the history boring.My rule of thumb
PostgreSQL, MySQL, or MariaDB, I reach for Goose. LibSQL/Turso, I reach for Geni. Not written in Go or Rust? I still consider both, because the CLI boundary matters more than the implementation language. Migrations are an operational concern before they’re an application concern.
And if the story starts needing drift management, online planning, policy gates, or fleet-wide orchestration, I stop trying to stretch a lightweight tool and pick something heavier on purpose. For everything else, Geni and Goose land right in the sweet spot: small, SQL-first, scriptable, and capable enough to keep schema changes moving without turning migrations into a platform.
Sources and further reading
- Geni GitHub repository: https://github.com/emilpriver/geni
- Turso: “Database migrations with Geni and libSQL”: https://turso.tech/blog/database-migrations-with-geni
- Goose documentation: https://pressly.github.io/goose/
- Goose GitHub repository: https://github.com/pressly/goose
- Atlas guide on Turso connection URLs: https://atlasgo.io/guides/sqlite/turso
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].