Skip to content

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.

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 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.

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.

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.

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.

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.
  • AutoMigrateAll does not take options, so a global WithForeignKeys() rollout calls the generic AutoMigrate[T] per model. A single constraint:fk tag 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.

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.

  • Migrations — additive sync, diffing, and reviewable migrations.
  • Composite keys — multiple pk fields 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.