Models & Auto-Migration
In Phase 1 you opened a *gorm.DB and watched it log SQL. Now we give it
something to talk about: a table. And here's the one idea that makes the rest of GORM click.
The mental model: a struct is the table
Stop thinking of "a struct" and "a table" as two things you have to keep in sync by hand. In GORM they're the same thing seen from two sides. The struct is how Go sees the table; the table is how the database stores the struct.
- Each field becomes a column.
- Each struct tag adds a constraint to that column (size, not-null, unique, an index).
- The struct name decides the table name (
User→users). - And one call —
AutoMigrate— makes the real database match the struct you wrote.
💡 Once you hold "the struct is the source of truth, the database is its shadow," GORM stops feeling like two parallel systems you have to babysit. You edit the struct; you re-run AutoMigrate; the table catches up.
We'll build the blog schema this whole guide uses. It has three tables — users, posts, comments —
and we start with User.
gorm.Model: the four fields you almost always want
Most tables need a primary key and some timestamps. Typing those into every struct gets old, so GORM
ships a tiny struct you embed to get them for free: gorm.Model.
What just happened: By embedding gorm.Model on the first line, User silently gained four fields
before Name and Email even appear:
| Field | Type | What it does |
|---|---|---|
ID |
uint |
The primary key. Auto-increments. You almost never set it by hand. |
CreatedAt |
time.Time |
GORM stamps it the moment the row is inserted. |
UpdatedAt |
time.Time |
GORM re-stamps it on every save. |
DeletedAt |
gorm.DeletedAt |
Enables soft delete — a "deleted" row sticks around but disappears from queries. (Full story in Phase 5.) |
So CreatedAt and UpdatedAt are managed for you — you don't write code to maintain them. That
one embed is why most GORM models start with gorm.Model.
📝
gorm.Modelis plain Go embedding, not magic. It's literally a struct with those four fields, and embedding it promotes them ontoUser. You could copy-paste the four fields instead and get the identical result.
A tour of field tags
The backtick string after a field — gorm:"..." — is a struct tag. GORM reads it to learn how
that column should be shaped. Multiple settings are separated by semicolons. Here are the ones you'll
reach for constantly:
What just happened: each tag maps to one piece of SQL:
size:100→ the column's max length (VARCHAR(100)). Default for strings is oftenVARCHAR(255).not null→ aNOT NULLconstraint; inserting a row without it errors.uniqueIndex→ a unique index, so two users can't share an email. Useindexfor a plain, non-unique index (faster lookups, duplicates allowed).default:'member'→ the column'sDEFAULT. If you create a user without a role, the DB fills inmember.column:about_me→ override GORM's auto-generated column name. Now the Go field isBiobut the column isabout_me.-→ "this field is not a column." GORM skips it entirely. (NoteavatarRawis lowercase, so it's also unexported — handy for internal scratch fields.)
⚠️ A struct tag is a single backtick string with no commas, only semicolons between settings.
gorm:"size:100, not null"is a classic typo — that comma makes GORM misread the second setting.
There are two more worth naming. primaryKey marks a field as the primary key (you'll see it below
when we skip gorm.Model), and you can combine settings freely: gorm:"size:255;not null;index".
Naming conventions: where table and column names come from
You didn't write a table name anywhere. GORM derives it, and the rules are worth memorizing because they're predictable:
- Struct → table: the name is snake_case and pluralized.
User→users,BlogPost→blog_posts,Comment→comments. - Field → column: snake_case, singular.
CreatedAt→created_at,Name→name.
When the convention doesn't fit — say your table is legacy and called tbl_users — override the whole
table name with a TableName() method:
What just happened: GORM checks for a TableName() string method on your model. If it finds one, it
uses that string verbatim instead of pluralizing. Now every query for User hits blog_users. (The
receiver (User) has no name because we don't use it — we only need the method to exist.)
AutoMigrate: make the database match the structs
You have structs. The database has nothing yet. AutoMigrate bridges the gap: hand it your models and
it creates the tables, columns, indexes, and foreign keys to match.
db
if err != nil
What just happened: GORM inspected each struct, compared it to the live database, and issued the SQL
needed to make reality match your code. On a fresh database that means three CREATE TABLE statements.
With logging on (from Phase 1), you'd see GORM emit something like this for User:
(
`id` integer PRIMARY KEY AUTOINCREMENT,
`created_at` datetime,
`updated_at` datetime,
`deleted_at` datetime,
`name` varchar(100) NOT NULL,
`email` text NOT NULL
);
ON `users`(`email`);
ON `users`(`deleted_at`);
What just happened: every piece traces back to the struct. id/created_at/updated_at/deleted_at
came from gorm.Model. name is NOT NULL and length-capped because of its tags. The unique index on
email is your uniqueIndex tag. GORM even indexes deleted_at on its own, because that's the column
soft-delete filters on. The struct really is the table — you're reading your own tags back as SQL.
Run AutoMigrate again with no changes and GORM does nothing — it's safe to call on every startup.
Add a field to the struct and re-run, and GORM issues an ALTER TABLE ... ADD COLUMN to catch up.
⚠️ AutoMigrate is additive only. It creates tables and adds missing columns, indexes, and foreign keys. It will never drop a column, never delete a table, and never change a column's type in a way that could lose data. Rename
EmailAddressand AutoMigrate adds a newemail_addresscolumn — the old
So the honest rule: AutoMigrate is great for development and getting started, not a complete migration strategy. Lean on it now; graduate from it later.
A model without gorm.Model
gorm.Model is a convenience, not a requirement. If you don't want the timestamps or soft-delete —
say a small lookup table — define your own primary key and skip the embed:
What just happened: with no gorm.Model, Tag has exactly two columns: id and name. The
primaryKey tag tells GORM that ID is the primary key (GORM also assumes a uint field named ID
is the PK by default, so here the tag is explicit insurance). No created_at, no updated_at, no
soft-delete — just the columns you declared. Use this when the four gorm.Model fields would be dead
weight.
With User defined and migrated, the table exists and is waiting for rows. Next we put data in and
read it back.
Recap
- A struct is the table. Fields become columns, struct tags add constraints, the struct name sets the table name.
- Embed
gorm.Modelto getID,CreatedAt,UpdatedAt(auto-managed), andDeletedAt(enables soft delete) without writing them yourself. - Field tags shape columns:
size,not null,uniqueIndex/index,default,column:to rename,-to ignore. Separate settings with semicolons, not commas. - Naming is automatic:
User→ tableusers,CreatedAt→ columncreated_at; override the table name with aTableName()method. AutoMigratemakes the DB match the structs — creating tables and adding missing columns/indexes — but it's additive only: it never drops or destructively retypes. Great in dev, not a full migration tool (Phase 8).- You can skip
gorm.Modeland declare your ownprimaryKeywhen you don't want timestamps or soft delete.
Quick check
[
{
"q": "What does embedding gorm.Model add to your struct?",
"choices": ["Only an ID field", "ID, CreatedAt, UpdatedAt, and DeletedAt", "A TableName method", "Nothing until you run AutoMigrate"],
"answer": 1,
"explain": "gorm.Model embeds four fields: ID (primary key), the auto-managed CreatedAt and UpdatedAt timestamps, and DeletedAt which enables soft delete."
},
{
"q": "You rename a struct field and re-run AutoMigrate. What happens to the old column?",
"choices": ["It is renamed to match", "It is dropped automatically", "It stays — a new column is added alongside it", "AutoMigrate refuses to run"],
"answer": 2,
"explain": "AutoMigrate is additive only. It adds a new column for the renamed field and leaves the old column (and its data) untouched. Real renames need a proper migration tool."
},
{
"q": "By default, which table does a struct named BlogPost map to?",
"choices": ["BlogPost", "blogpost", "blog_posts", "blogposts"],
"answer": 2,
"explain": "GORM converts the struct name to snake_case and pluralizes it: BlogPost becomes blog_posts. Override it with a TableName() method if needed."
}
]
← Phase 1: What GORM Is & Connecting · Guide overview · Phase 3: Create & Read →
Check your understanding
1. What does embedding gorm.Model add to your struct?
2. You rename a struct field and re-run AutoMigrate. What happens to the old column?
3. By default, which table does a struct named BlogPost map to?