GORM Models Tags and Naming Conventions You Should Know
How GORM turns a Go struct into a table: naming rules, tags for size, index and default, read-only fields, and what PostgreSQL really received in a run

A GORM model is a Go struct with a set of conventions around it. Most of the time you write a struct, add no tags, and GORM does the right thing. The conventions matter when you point GORM at a table somebody else designed, when a column needs a size or a default, or when a field must be protected from updates. This article lists the rules, then runs a model through PostgreSQL 18.6 and prints what GORM decided and what the database built, so you can see the effect of each tag and not guess.
Tested with Go 1.25.6, GORM v1.31.2, gorm.io/driver/postgres v1.6.3, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It follows the GORM tutorial, which covers the basic calls, and it feeds associations and the migration articles in the database cornerstone.
The naming rules
GORM's default NamingStrategy applies these rules:
| Go | Database | Note |
|---|---|---|
struct UserAccount | table user_accounts | snake case, pluralised |
field FirstName | column first_name | snake case |
field HTTPCode | column http_code | initialisms are kept together |
field UserID | column user_id | |
field ID | primary key | any ID of an integer type |
CreatedAt, UpdatedAt | filled automatically | time.Time or an integer |
DeletedAt of gorm.DeletedAt | soft delete | see the soft delete article |
Two overrides are common. A TableName() method on the struct returns the table name for that model, and a NamingStrategy in the config changes the rule for every model (TablePrefix, SingularTable, NameReplacer). The method wins for a model that has it. Use the strategy for a house style and the method for the one table that does not follow it.
The lab
The lab defines two models. UserAccount uses most of the tags worth knowing, and PlainThing uses none and exists to show the defaults. After AutoMigrate, the program asks GORM's parser what it decided, then queries information_schema for what PostgreSQL has:
package main
import (
"fmt"
"log"
"strings"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// Gorm's defaults: ID is the primary key, CreatedAt and UpdatedAt are filled in, the table is the plural snake case.
type UserAccount struct {
ID uint
FirstName string `gorm:"size:80;not null"`
Email string `gorm:"uniqueIndex;type:varchar(120)"`
Nickname *string
Age int `gorm:"default:18"`
Internal string `gorm:"-"`
Token string `gorm:"column:api_token;->;<-:create"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (UserAccount) TableName() string { return "accounts" }
type PlainThing struct {
ID uint
HTTPCode int
UserID uint
}
func must(err error) {
if err != nil {
log.Fatal(err)
}
}
func main() {
base := "host=127.0.0.1 port=55432 user=postgres password=secret dbname=lab sslmode=disable"
admin, err := gorm.Open(postgres.Open(base), &gorm.Config{Logger: logger.Discard})
must(err)
must(admin.Exec("drop schema if exists l0707 cascade").Error)
must(admin.Exec("create schema l0707").Error)
db, err := gorm.Open(postgres.Open(base+" search_path=l0707"), &gorm.Config{Logger: logger.Discard})
must(err)
must(db.AutoMigrate(&UserAccount{}, &PlainThing{}))
// Ask the parser what it decided, then ask the database what it built.
for _, m := range []any{&UserAccount{}, &PlainThing{}} {
stmt := &gorm.Statement{DB: db}
must(stmt.Parse(m))
s := stmt.Schema
var cols []string
for _, f := range s.Fields {
if f.DBName != "" {
cols = append(cols, f.DBName)
}
}
fmt.Printf("%s -> table %q columns %s\n", s.Name, s.Table, strings.Join(cols, ","))
}
var rows []struct {
TableName string
ColumnName string
DataType string
IsNullable string
Default *string
}
db.Raw(`select table_name, column_name, data_type, is_nullable, column_default as "default"
from information_schema.columns where table_schema = 'l0707' and table_name = 'accounts' order by ordinal_position`).Scan(&rows)
for _, r := range rows {
d := "-"
if r.Default != nil {
d = *r.Default
}
fmt.Printf("accounts.%s %s null=%s default=%s\n", r.ColumnName, r.DataType, r.IsNullable, d)
}
// Read-only and create-only permissions on a field.
u := UserAccount{FirstName: "Ada", Email: "[email protected]", Token: "t1"}
must(db.Create(&u).Error)
db.Model(&u).Updates(UserAccount{Token: "t2", FirstName: "Ada L"})
var got UserAccount
db.First(&got, u.ID)
fmt.Println("token after update (create-only):", got.Token, "name:", got.FirstName)
fmt.Println("zero Age default applied:", got.Age)
var idx []string
db.Raw(`select indexname from pg_indexes where schemaname = 'l0707' and tablename = 'accounts' order by 1`).Scan(&idx)
fmt.Println("indexes:", strings.Join(idx, ", "))
}UserAccount -> table "accounts" columns id,first_name,email,nickname,age,api_token,created_at,updated_at
PlainThing -> table "plain_things" columns id,http_code,user_id
accounts.id bigint null=NO default=nextval('accounts_id_seq'::regclass)
accounts.first_name character varying null=NO default=-
accounts.email character varying null=YES default=-
accounts.nickname text null=YES default=-
accounts.age bigint null=YES default=18
accounts.api_token text null=YES default=-
accounts.created_at timestamp with time zone null=YES default=-
accounts.updated_at timestamp with time zone null=YES default=-
token after update (create-only): t1 name: Ada L
zero Age default applied: 18
indexes: accounts_pkey, idx_accounts_emailWhat the parser decided
The first two output lines are GORM's own view. UserAccount maps to the table "accounts", because the TableName() method overrode the default user_accounts, and its columns are id,first_name,email,nickname,age,api_token,created_at,updated_at. The field Internal, tagged gorm:"-", is not in the list: it exists in Go and nowhere else. PlainThing maps to "plain_things" with id,http_code,user_id, which shows the initialism rule: HTTPCode did not become h_t_t_p_code.
Use the parser view when a column is missing from a query and you are not sure why. A field that is unexported, tagged -, or of a type GORM cannot map does not appear.
What the database built
The information_schema lines show the real DDL:
idisbigint, not null, with a default from the sequenceaccounts_id_seq. An unsigneduintfield maps tobigintin PostgreSQL, which has no unsigned type.first_nameischaracter varyingand not null. Thesize:80tag made itvarchar(80), whichinformation_schema.data_typereports without the length. The length is incharacter_maximum_length.emailischaracter varyingbecause oftype:varchar(120), and it is nullable. TheuniqueIndextag creates an index, not a constraint on nullability, so an account with an empty email string is allowed, once. If a column must not be null, saynot null.nicknameistext, nullable, because the field is*string. A pointer is how you tell GORM that NULL is a meaningful value. A plainstringwould store an empty string.ageisbigintwith default18.api_tokenistext. The column name came from thecolumn:tag, not from the field nameToken.- The two timestamps are
timestamp with time zoneand nullable.
The last line of output lists the indexes: accounts_pkey and idx_accounts_email. GORM names an index idx_<table>_<column> by default, which is worth knowing when you later write a migration that has to drop it.
Defaults and zero values
The tag default:18 produced a surprise worth planning for. The program created an account without setting Age, the Go zero value is 0, and the row came back with zero Age default applied: 18. When a field has a default and the value is the zero value, GORM leaves the column out of the INSERT, and the database fills it in.
That is the right behaviour for "unset", and the wrong one for a legitimate zero. You cannot insert an age of 0 through a plain int field that has a default. The fixes:
- Make the field a pointer (
*int). A pointer to 0 is not the zero value, so GORM sends it. - Use
sql.NullInt64or anotherValuer, which carries a validity flag. - Drop the default tag and set the default in your constructor.
The same applies to bool with default:true: a false is the zero value and you can never insert it. Use *bool or avoid the default tag, and decide where the default lives (the database, or Go) in one place.
The rule is symmetrical on update. Updates(User{Age: 0}) with a struct skips the zero field. Updates(map[string]any{"age": 0}) writes it. Everything in the tutorial about partial updates comes back to this one rule about zero values.
Permissions on a field
The Token field carries gorm:"column:api_token;->;<-:create". The two arrows are field permissions:
| Tag | Meaning |
|---|---|
-> | read only (no writes) |
->;<-:create | read, and write on create only |
<-:create | write on create |
<-:update | write on update |
<-:false | no write |
->:false | no read |
- | ignore completely |
The lab created an account with Token: "t1", then called Updates(UserAccount{Token: "t2", FirstName: "Ada L"}). The result was token after update (create-only): t1 name: Ada L. The name changed, and the token did not. GORM dropped the token from the UPDATE without an error, which is the point of the tag and also the risk: a write that silently does nothing is a bug if you did not expect it. Document read only fields where they are declared.
This is a useful guard for an API key, an owner id or a tenant id. It is not an authorisation layer, because a raw SQL statement bypasses it.
Embedded structs and gorm.Model
Two more conventions save typing. gorm.Model is a struct with ID, CreatedAt, UpdatedAt and DeletedAt, and embedding it gives a model all four. Embedding any struct flattens its fields into the table, and the embedded and embeddedPrefix tags control the column names:
type Audit struct {
CreatedBy string
UpdatedBy string
}
type Invoice struct {
ID uint
Audit `gorm:"embedded;embeddedPrefix:audit_"`
}That builds audit_created_by and audit_updated_by. Embedding is a clean way to share columns, but gorm.Model brings soft delete with it, and every query on that model then adds deleted_at IS NULL. Embed gorm.Model only when you want that, and read the soft delete article first.
Decisions to make once
- Names. Accept the defaults. Add
TableName()only where an existing table forces it. - Keys. Use
uintorint64IDs from the database. For UUIDs, set the type and a default in the tag or the migration, because GORM does not generate them for you. - Nullability. Say
not nullwhere you mean it, and use pointers for columns that are truly optional. - Defaults. Pick the database or Go for each default and stay consistent, because of the zero value rule.
- Sizes. Add
sizefor strings that have a limit, so the database enforces it.
Checklist
- Print the parsed schema when a column is missing, and the live DDL when a type is surprising.
uniqueIndexdoes not implynot null.- A field with a default tag cannot be inserted as its zero value unless it is a pointer or a
Valuer. - Field permissions drop writes silently, so use them on purpose and say so in a comment.
- Embedding
gorm.Modelturns on soft delete.
The tag reference is in the GORM models guide and the full list of field permissions is in the GORM schema reference.


