Skip to content
gohacks.dev
Databases

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

9 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

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:

GoDatabaseNote
struct UserAccounttable user_accountssnake case, pluralised
field FirstNamecolumn first_namesnake case
field HTTPCodecolumn http_codeinitialisms are kept together
field UserIDcolumn user_id
field IDprimary keyany ID of an integer type
CreatedAt, UpdatedAtfilled automaticallytime.Time or an integer
DeletedAt of gorm.DeletedAtsoft deletesee 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:

main.goGo
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, ", "))
}
Text
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_email

What 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:

  • id is bigint, not null, with a default from the sequence accounts_id_seq. An unsigned uint field maps to bigint in PostgreSQL, which has no unsigned type.
  • first_name is character varying and not null. The size:80 tag made it varchar(80), which information_schema.data_type reports without the length. The length is in character_maximum_length.
  • email is character varying because of type:varchar(120), and it is nullable. The uniqueIndex tag 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, say not null.
  • nickname is text, nullable, because the field is *string. A pointer is how you tell GORM that NULL is a meaningful value. A plain string would store an empty string.
  • age is bigint with default 18.
  • api_token is text. The column name came from the column: tag, not from the field name Token.
  • The two timestamps are timestamp with time zone and 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.NullInt64 or another Valuer, 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:

TagMeaning
->read only (no writes)
->;<-:createread, and write on create only
<-:createwrite on create
<-:updatewrite on update
<-:falseno write
->:falseno 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:

Go
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 uint or int64 IDs 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 null where 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 size for 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.
  • uniqueIndex does not imply not 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.Model turns 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.