Composite keys
A composite primary key spans more than one column — a (tenant_id, user_id) membership, an (order_id, line_no) line item. In LiteORM you declare one by tagging several fields pk, and the repository then addresses each row by its whole key. This guide covers declaring the key, keyed access with Get(ctx, a, b), and how composite keys interact with associations, soft delete, and migrations.
For exhaustive API detail, see the reference at pkg.go.dev/liteorm.org/orm.
Declaring a composite key
Section titled “Declaring a composite key”Tag every column that is part of the key with pk. The key spans them in declaration order, and — unlike a bare int64 ID — a composite key is never auto-increment, because you assign each part yourself:
type Membership struct { TenantID int64 `orm:"tenant_id,pk"` UserID int64 `orm:"user_id,pk"` Role string}
func (Membership) TableName() string { return "memberships" }AutoMigrate renders this as a table-level constraint — PRIMARY KEY ("tenant_id", "user_id") — with the member columns declared plainly (no inline PRIMARY KEY, no AUTOINCREMENT) on every backend. Compare this to a single-column key, where AutoMigrate renders the key inline on the column itself.
In the resolved schema, Schema.PKs is always the full, ordered list of key fields, while Schema.PK — the single-PK convenience — is nil for a composite key. Code that must work for both key shapes should read PKs.
Keyed access: Get(ctx, a, b)
Section titled “Keyed access: Get(ctx, a, b)”Repo.Get takes one value per key column, in declaration order:
repo := orm.NewRepo[Membership](sess)
m, err := repo.Get(ctx, tenantID, userID) // WHERE tenant_id = ? AND user_id = ?The count is checked: passing the wrong number of key values is a hard error, not a silent partial match. Update and Delete are keyed the same way — they build a WHERE clause over every key column from the value you pass, so they target exactly one row:
m.Role = "admin"err = repo.Update(ctx, &m) // WHERE tenant_id = ? AND user_id = ?err = repo.Delete(ctx, &m) // same key matchCreate, Save, Find, and the read finishers (First/Count/Exists) all work unchanged on a composite-key model.
What needs a single-column key
Section titled “What needs a single-column key”A few batch helpers key rows through a WHERE pk IN (...) clause, which a composite key can’t express, so they require a single-column key and return a clear error otherwise:
| Method | Composite key? |
|---|---|
Get(ctx, a, b) |
yes — one value per column |
Update / Delete / Save |
yes |
GetByKeys(ctx, keys...) |
no — single-column PK only |
FindInBatches(ctx, n, fn) |
no — keyset chunks need one key column |
For a composite-key model, reach these batch shapes with an explicit query.Select on the same session instead.
Composite keys and associations
Section titled “Composite keys and associations”Association loading joins on a single foreign-key column, so a relation whose join key is a composite key isn’t inferred from struct shape. Model those links explicitly:
- The natural table for a
(tenant_id, user_id)membership is itself a join table connecting a tenant and a user. Model the tenant↔user link as a many-to-many via that table and load it withorm.Load, treatingMembershipas an ordinary keyed row when you need itsRolecolumn. - When you need a belongs-to from a composite-key model, keep the pointing column single (e.g.
TenantID) and let that drive the relation; a composite key does not block ordinary single-column FKs elsewhere on the struct.
See associations for how orm.Load and the orm.Assoc handle work.
Composite keys and soft delete
Section titled “Composite keys and soft delete”Soft delete is orthogonal to the key shape. Add the sql.NullTime field tagged soft_delete and Delete soft-deletes, reads exclude deleted rows by default, and IncludeDeleted() / OnlyDeleted() / ForceDelete give the opt-outs — all keyed by the full composite key:
type Membership struct { TenantID int64 `orm:"tenant_id,pk"` UserID int64 `orm:"user_id,pk"` Role string DeletedAt sql.NullTime `orm:"deleted_at,soft_delete"`}One interaction is worth knowing: the partial unique index that frees a unique value on soft delete is built for unique-tagged columns, not for the primary key. The primary key stays a full constraint, so a soft-deleted (tenant, user) row still occupies that key pair. If you need to re-add a soft-deleted membership, Restore it rather than Create a duplicate — see soft delete.
Composite keys and migrations
Section titled “Composite keys and migrations”AutoMigrate creates the composite key as a table-level PRIMARY KEY (...) when it first creates the table. The primary key is fixed at creation: like every constraint change, altering a table’s key afterward is destructive and is handled through a reviewable migration, never applied silently. So decide the key when you first model the table.
See also
Section titled “See also”- Indexes & constraints — the
pktag alongside unique/index/check. - CRUD with the Repo —
Get/Update/Deleteand the batch helpers. - Soft delete — the partial unique index and
Restore. - Migrations — how the key is emitted and why it’s create-time.
- Associations — modeling links that a composite key can’t infer.