Database Grammars
Learn how go-migration generates SQL for PostgreSQL, MySQL, and SQLite using database grammars.
Database Grammars
go-migration uses grammars to translate its fluent schema builder API into database-specific SQL. Each grammar knows how to map column types, modifiers, and operations to the correct syntax for its target database engine.
Three grammars are supported out of the box:
- PostgreSQL (default)
- MySQL
- SQLite
Selecting a Grammar
When using the CLI (app.Run()), the grammar is auto-resolved from the driver field of the default connection in your migration.json config file. No manual grammar selection is needed:
| Driver | Grammar |
|---|---|
postgres | PostgreSQL |
mysql | MySQL |
sqlite (or the alias sqlite3) | SQLite |
For manual setup, pass a grammar to migrator.New() using the migrator.WithGrammar() option. The grammar drives the schema builder, Fresh(), the migration lock, and — since v1.2.0 — the tracking table: its DDL and bind placeholders come from the grammar too. Without WithGrammar, only the tracker falls back to PostgreSQL syntax, so always pass a grammar, and in particular for MySQL and SQLite.
Use the PostgresGrammar to generate PostgreSQL SQL.
package main
import (
"database/sql"
"log"
_ "github.com/lib/pq"
"github.com/gopackx/go-migration/pkg/migrator"
"github.com/gopackx/go-migration/pkg/schema/grammars"
)
func main() {
db, err := sql.Open("postgres", "postgres://user:password@localhost:5432/mydb?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
m := migrator.New(db, migrator.WithGrammar(&grammars.PostgresGrammar{}))
// Register migrations and run...
}Use the MySQLGrammar to generate MySQL-compatible SQL.
package main
import (
"database/sql"
"log"
_ "github.com/go-sql-driver/mysql"
"github.com/gopackx/go-migration/pkg/migrator"
"github.com/gopackx/go-migration/pkg/schema/grammars"
)
func main() {
db, err := sql.Open("mysql", "user:password@tcp(localhost:3306)/mydb")
if err != nil {
log.Fatal(err)
}
defer db.Close()
m := migrator.New(db, migrator.WithGrammar(&grammars.MySQLGrammar{}))
// Register migrations and run...
}Use the SQLiteGrammar to generate SQLite-compatible SQL.
package main
import (
"database/sql"
"log"
_ "modernc.org/sqlite" // pure Go, registers as "sqlite"
"github.com/gopackx/go-migration/pkg/migrator"
"github.com/gopackx/go-migration/pkg/schema/grammars"
)
func main() {
db, err := sql.Open("sqlite", "./app.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
m := migrator.New(db, migrator.WithGrammar(&grammars.SQLiteGrammar{}))
// Register migrations and run...
}The grammar only affects SQL generation. Your migration code and schema builder calls stay exactly the same regardless of which database you target.
Type Mapping Table
The table below shows how each go-migration column type maps to native SQL types across all three grammars.
| go-migration Method | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
bp.ID() | BIGSERIAL PRIMARY KEY | BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY | INTEGER PRIMARY KEY AUTOINCREMENT |
bp.String(name, n) | VARCHAR(n) | VARCHAR(n) | TEXT |
bp.Text(name) | TEXT | TEXT | TEXT |
bp.Integer(name) | INTEGER | INT | INTEGER |
bp.BigInteger(name) | BIGINT | BIGINT | INTEGER |
bp.Boolean(name) | BOOLEAN | TINYINT(1) | INTEGER |
bp.Timestamp(name) | TIMESTAMPTZ | TIMESTAMP | TEXT |
bp.Date(name) | DATE | DATE | TEXT |
bp.Decimal(name, p, s) | DECIMAL(p,s) | DECIMAL(p,s) | REAL |
bp.Float(name) | DOUBLE PRECISION | DOUBLE | REAL |
bp.UUID(name) | UUID | CHAR(36) | TEXT |
bp.JSON(name) | JSONB | JSON | TEXT |
bp.Binary(name) | BYTEA | BLOB | BLOB |
SQLite has a limited type system. Most types collapse to its core affinities: strings, dates, timestamps, UUIDs, and JSON are all stored as TEXT, BigInteger and Boolean map to INTEGER, and Decimal/Float map to REAL. The String length argument is ignored. Your application code should handle any necessary type conversions.
Key Differences Between Grammars
PostgreSQL
- Uses
BIGSERIALfor auto-incrementing IDs - Has native
UUIDandJSONBtypes - Uses
BYTEAfor binary data BOOLEANis a true boolean type- Timestamps use
TIMESTAMPTZ(timestamp with time zone)
MySQL
- Uses
BIGINT UNSIGNED AUTO_INCREMENTfor IDs - UUIDs are stored as
CHAR(36) - Has native
JSONtype (MySQL 5.7+) - Booleans use
TINYINT(1)(0 or 1)
SQLite
- Uses
INTEGER PRIMARY KEY AUTOINCREMENTfor IDs - Most types map to SQLite's core affinities (
INTEGER,TEXT,REAL,BLOB) - Strings, dates, and timestamps are all stored as
TEXT(theStringlength is ignored) - No native UUID or JSON types — stored as
TEXT - No native boolean — stored as integer (0 or 1)
Behaviour per Database
| PostgreSQL | MySQL | SQLite | |
|---|---|---|---|
| Bind placeholder | $1, $2, … | ? | ? |
| Identifier quoting | "name" | `name` | "name" |
| Transactional DDL | Yes | No — DDL commits implicitly | Yes |
Fresh() | DROP SCHEMA public CASCADE + CREATE SCHEMA public | Drops each base table with FOREIGN_KEY_CHECKS = 0 | Drops each table with PRAGMA foreign_keys = OFF |
| Migration lock | pg_try_advisory_lock | GET_LOCK | None (file lock) |
See Transactions, Fresh, and Concurrency and Locking for details.
Identifier Quoting
Table, column, index, and tracking-table names are always quoted, with the delimiter escaped by doubling it: "a""b" on PostgreSQL and SQLite, `a``b` on MySQL. Names compared as values in the HasTable/HasColumn catalog queries are single-quoted with ' doubled (and, on MySQL, \ doubled). Any name the database accepts can therefore be used, including ones containing quote characters.
Because names are quoted, they are case-sensitive on PostgreSQL. In schema-builder names (Create, Alter, HasTable, ...) a dot is part of the name. The tracking table name (migration_table / WithTableName) is the exception: it may be schema-qualified, and each dot-separated part is quoted separately — app.migrations becomes "app"."migrations" on PostgreSQL (schema app), `app`.`migrations` on MySQL (database app), and "app"."migrations" on SQLite (attached database app). The schema must already exist.
Custom Grammars
To support another database, or to change the generated SQL, implement the schema.Grammar interface and pass your type to migrator.WithGrammar. As of v1.2.0 the interface is:
type Grammar interface {
CompileCreate(blueprint *Blueprint) ([]string, error)
CompileAlter(blueprint *Blueprint) ([]string, error)
CompileDrop(table string) string
CompileDropIfExists(table string) string
CompileRename(from, to string) string
CompileHasTable(table string) string
CompileHasTableIn(schema, table string) string
CompileHasColumn(table, column string) string
CompileListTables() string
CompileDropAllTables(tables []string) []string
CompileTrackingTable(table string) string
QuoteIdentifier(name string) string
Placeholder(index int) string
CompileTryLock(key string) string
CompileUnlock(key string) string
CompileColumnType(col ColumnDefinition) (string, error)
}Upgrading a Custom Grammar to v1.2.0
The built-in grammars already implement everything below. If you implement schema.Grammar yourself, the interface changed in v1.2.0:
Changed signatures
CompileCreate(bp) ([]string, error)now returns a slice — theCREATE TABLEstatement followed by any separateCREATE INDEXstatements. Each element is executed individually, because many drivers (pgx stdlib, go-sql-driver/mysql) reject multi-statementExec. It was previouslyCompileCreate(bp) (string, error).CompileDropAllTables(tables []string) []stringreceives the table list produced byCompileListTables()and returns the statements to run, in order, on one connection. It was previouslyCompileDropAllTables() string. Grammars that can drop everything without the list (like PostgreSQL'sDROP SCHEMA ... CASCADE) may ignore it.
New methods
| Method | Purpose |
|---|---|
CompileListTables() string | A query returning one row per table name in the current database/schema, used by Fresh() |
CompileTrackingTable(table string) string | CREATE TABLE IF NOT EXISTS for the tracking table (id, migration unique, batch, created_at), with the possibly schema-qualified name quoted by schema.QuoteQualified(g, table) |
QuoteIdentifier(name string) string | The name quoted as one identifier, with the delimiter escaped by doubling it ("a""b", `a``b`). schema.QuoteQualified applies it to each dot-separated part of the tracking table name |
CompileHasTableIn(schema, table string) string | Like CompileHasTable, but looks in the given schema (PostgreSQL schema, MySQL database, SQLite attached database); used for a schema-qualified tracking table |
Placeholder(index int) string | The bind parameter for the 1-based index ($1 on PostgreSQL, ? on MySQL/SQLite) |
CompileTryLock(key string) string | A non-blocking query that tries to take a session-level named lock. It takes the lock key as its only bind parameter (Placeholder(1)) and returns one boolean-ish column: 1/true = acquired; 0, false, or NULL = not |
CompileUnlock(key string) string | Releases the lock taken by CompileTryLock, on the same connection; same parameter and result shape (1/true = released) |
Return "" from both CompileTryLock and CompileUnlock if the database needs no migration lock, as SQLite does — the migrator then skips locking. The lock must belong to the database session, not to a transaction, so that it can be held across several migration transactions and is released by the server when the connection closes.
For reference, the built-in lock queries are:
-- PostgreSQL
SELECT pg_try_advisory_lock(hashtext($1))
SELECT pg_advisory_unlock(hashtext($1))
-- MySQL
SELECT GET_LOCK(CONCAT(DATABASE(), ':', ?), 0)
SELECT RELEASE_LOCK(CONCAT(DATABASE(), ':', ?))What's Next?
- Seeders — populate your database with test data
- Column Types — full reference of all schema builder column methods
- Connections — manage multiple database connections