Indexes & constraints
Indexes and constraints are declared on your model with struct tags, and AutoMigrate turns them into DDL when it creates the table. This guide covers the unique, index, notnull, check, and foreign-key tags, the partial unique index that keeps soft delete honest, and opt-in foreign-key enforcement.
For exhaustive API detail, see the reference at pkg.go.dev/liteorm.org/orm.
The tags at a glance
Section titled “The tags at a glance”Every constraint below is a column tag — an option in the comma-separated orm:"..." form (or the semicolon-separated gorm:"..." form, read natively). A bare int64 ID field is the auto-increment primary key by convention, so you rarely tag it.
| I want… | Tag |
|---|---|
| a unique column | unique |
| a non-unique secondary index | index (or index:name to name it) |
a NOT NULL column |
notnull |
a CHECK constraint |
check:EXPR |
a DEFAULT value |
default:VALUE |
| this belongs-to relation’s FK enforced | constraint:fk on the relation field |
type User struct { ID int64 Email string `orm:"email,unique,notnull"` Name string `orm:"name,index"` Age int `orm:"age,check:age >= 0"` Plan string `orm:"plan,default:free"`}
func (User) TableName() string { return "users" }notnull renders NOT NULL on the column, default: renders DEFAULT (a string default is quoted as a SQL literal, matching gorm; wrap a SQL expression in parentheses to pass it through verbatim), and check: renders an inline CHECK (expr). Because the native orm tag is comma-separated, a check: or default: expression that itself contains a comma is truncated — use the semicolon-separated gorm tag for those:
Age int `gorm:"column:age;check:age BETWEEN 0 AND 150"`Unique columns
Section titled “Unique columns”unique emits a CREATE UNIQUE INDEX named ux_<table>_<column>, run right after the CREATE TABLE:
Slug string `orm:"slug,unique"`// → CREATE UNIQUE INDEX "ux_posts_slug" ON "posts" ("slug")A violation surfaces as a normalized liteorm.ErrUniqueViolation regardless of backend — see errors.
Unique columns on soft-delete models
Section titled “Unique columns on soft-delete models”When the model has a soft-delete column, a soft-deleted row would normally keep occupying its unique value, so you could never reuse a slug or email that belonged to a deleted record. AutoMigrate avoids this by building the unique index as a partial index scoped to live rows:
- SQLite, Postgres, and SQL Server get a filtered index:
... ("slug") WHERE "deleted_at" IS NULL. - MySQL, which has no partial index, gets an equivalent functional index that returns
NULL(and so drops out of the uniqueness check) for soft-deleted rows.
The effect is identical everywhere: once a row is soft-deleted its unique value is released, and a new live row can take it. You get this automatically for any soft-delete model you migrate. See soft delete for the full behavior.
Secondary indexes
Section titled “Secondary indexes”index emits a plain CREATE INDEX named ix_<table>_<column>, or the name you give with index:name:
type Event struct { ID int64 UserID int64 `orm:"user_id,index"` // → ix_events_user_id Kind string `orm:"kind,index:idx_event_kind"` // → idx_event_kind CreatedAt time.Time `orm:"created_at,index"`}Tags declare single-column indexes. A multi-column (composite) secondary index is not generated from tags — write it as a plain CREATE INDEX (...) in a reviewable migration (see migrations) or run it directly. The only multi-column index AutoMigrate emits on its own is the primary key of a composite-key model.
Composite primary keys
Section titled “Composite primary keys”Tag more than one field pk and the key spans all of them, in declaration order, as a table-level PRIMARY KEY (a, b) — never auto-increment, since you assign the parts yourself:
type Membership struct { TenantID int64 `orm:"tenant_id,pk"` UserID int64 `orm:"user_id,pk"` Role string}
func (Membership) TableName() string { return "memberships" }// → PRIMARY KEY ("tenant_id", "user_id")Reads and writes then address a row by its whole key: repo.Get(ctx, tenantID, userID). See composite keys for the full story.
Foreign keys are opt-in
Section titled “Foreign keys are opt-in”By default LiteORM ships belongs-to and has-many relations as plain columns — no FOREIGN KEY constraint — so additive migration and bulk loads stay simple. When you want the constraint enforced, opt in either globally or per relation.
Globally, pass orm.WithForeignKeys() to AutoMigrate: every belongs-to relation on the model gets a FOREIGN KEY referencing the target’s primary key.
err := orm.AutoMigrate[Order](ctx, sess, orm.WithForeignKeys())Per relation, tag the relation field constraint:fk and leave the rest as plain columns:
type Order struct { ID int64 CustomerID int64 `orm:"customer_id"` Customer *Customer `orm:"fk:customer_id,constraint:fk"` // this FK is emitted}Two rules follow from how the constraint is emitted:
- It is written only into a newly created table, so migrate the referenced table first (or use
AutoMigrateAll, which migrates its arguments in the order you list them). Adding a constraint to a table that already exists is never automatic — it can fail on existing rows — so do that through a reviewable migration. AutoMigrateAlldoes not take options, so a globalWithForeignKeys()rollout calls the genericAutoMigrate[T]per model. A singleconstraint:fktag works under either path, since it lives on the model.
On SQLite, foreign-key enforcement also requires the foreign_keys pragma to be on at the connection — see connecting for how to set backend pragmas.
How AutoMigrate emits all of this
Section titled “How AutoMigrate emits all of this”AutoMigrate is introspection-gated and additive. On a table that does not exist it emits the full CREATE TABLE — inline column constraints (NOT NULL, DEFAULT, CHECK), the table-level composite primary key, and any opted-in foreign keys — then runs each CREATE [UNIQUE] INDEX. On a table that already exists it only ADD COLUMNs fields the database is missing and creates any declared index the live table lacks; it never drops a column, retypes one, or drops an index. So adding an index or unique tag to a model that already has a table is realized on the next AutoMigrate, but removing one — like any destructive change — is a reviewable migration, never a silent DROP INDEX.
See migrations for the diff and reviewable-migration half of the story.
See also
Section titled “See also”- Migrations — additive sync, diffing, and reviewable migrations.
- Composite keys — multiple
pkfields and keyed access. - Soft delete — the partial unique index in context.
- Errors — the normalized
ErrUniqueViolation/ constraint errors you’ll hit. - Declaring models — the full tag grammar.