How to Use GORM

A Complete Guide for Go

Initialize GORM by opening a connection and calling AutoMigrate on your struct to create the database table.

The struct is the schema

You write a SQL query in a string. You change a column name in the database. You forget to update the string. The Go compiler says everything is fine. The program compiles. The server starts. The request hits the endpoint and returns a database error.

This is the friction of raw SQL in a typed language. You have types for your data, but your queries are just text. GORM tries to bridge that gap. It is an ORM, an Object-Relational Mapper. You define Go structs. GORM maps those structs to database tables. You call methods on a database instance. GORM translates those method calls into SQL.

You write Go code. GORM writes SQL. The goal is to keep your database operations type-safe and concise without dropping into raw strings for every query.

How the mapping works

GORM treats your structs as the source of truth. When you define a struct, GORM inspects the fields and infers the table schema. By default, the table name is the pluralized lowercase version of the struct name. A User struct becomes a users table. A Post struct becomes a posts table.

Fields map to columns. The field name becomes the column name, converted to snake_case. FirstName becomes first_name. If you need a different column name, you use a struct tag. GORM also looks for special field names. If a struct has an ID field, GORM assumes it is the primary key. If it has CreatedAt, UpdatedAt, or DeletedAt, GORM manages those timestamps automatically.

This convention-over-configuration approach speeds up development. You get a working schema with zero migration files. It also hides the database details. You can switch from SQLite to PostgreSQL by changing one import and one connection string, and the rest of the code stays the same.

Minimal setup

Here is the simplest way to get GORM running. You import the driver, open a connection, define a model, and run AutoMigrate.

package main

import (
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

// User maps to the 'users' table.
// ID is the primary key. Name is a string column.
type User struct {
	ID   uint
	Name string
}

func main() {
	// Open returns a *gorm.DB instance.
	// It does not test the connection immediately.
	// It sets up the connection pool and dialect.
	db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
	if err != nil {
		panic("failed to connect database")
	}

	// AutoMigrate creates tables if they don't exist.
	// It adds missing columns. It does not drop columns.
	// This is safe for development but not a full migration tool.
	db.AutoMigrate(&User{})
}

The gorm.Open function returns a *gorm.DB object. This object is not a single connection. It is a handle to a connection pool. You can pass this db instance around your application. It is safe for concurrent use.

AutoMigrate is the method that creates the schema. It checks the database for the table. If the table is missing, it creates it. If the table exists, it compares the struct fields to the columns. It adds any missing columns. It does not remove columns that are no longer in the struct. It does not rename columns. This makes AutoMigrate additive. It is great for local development where you want the database to match your code instantly. In production, you usually want more control over schema changes, so teams often use dedicated migration tools alongside GORM.

GORM writes SQL. You write Go. Trust the struct, not the magic.

CRUD operations

Once the table exists, you use the db instance to perform Create, Read, Update, and Delete operations. GORM uses a chaining pattern. You call methods like Where, Order, and Limit. These methods do not execute SQL. They build a query object. The query only runs when you call a terminal method like Find, First, or Create.

func createUser(db *gorm.DB) {
	user := User{Name: "Alice"}

	// Create inserts the record.
	// It sets the ID field on the struct after insertion.
	// It returns an error if the insert fails.
	result := db.Create(&user)
	if result.Error != nil {
		// Handle error
	}

	// user.ID is now populated with the database ID.
	println("Created user with ID:", user.ID)
}

func findUser(db *gorm.DB, id uint) {
	var user User

	// First fetches the first record matching the condition.
	// It returns ErrRecordNotFound if no row matches.
	// The query executes here.
	err := db.First(&user, id).Error
	if err != nil {
		// Handle not found or other errors
	}

	println("Found user:", user.Name)
}

The Create method takes a pointer to the struct. It generates an INSERT statement. After the insert, GORM populates the ID field in the struct with the value from the database. This saves you from running a separate query to get the ID.

The First method fetches a single record. It adds a LIMIT 1 to the query automatically. If no record matches, it returns a specific error. You can check for this error using errors.Is.

import "gorm.io/gorm"

// ...

err := db.First(&user, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
	// Handle missing user
}

Update operations use the Model method to specify the target record, followed by Update or Updates. Update changes a single column. Updates changes multiple columns using a map or a struct.

func updateUser(db *gorm.DB, id uint) {
	// Model specifies the base query.
	// Update changes the Name column for the record with the given ID.
	// It generates an UPDATE statement.
	db.Model(&User{}).Where("id = ?", id).Update("Name", "Bob")
}

Delete operations use the Delete method. GORM supports soft deletes if your struct has a DeletedAt field. Instead of removing the row, GORM sets the DeletedAt timestamp. Future queries automatically filter out soft-deleted records.

func deleteUser(db *gorm.DB, id uint) {
	// Delete removes the record.
	// If the struct has DeletedAt, this is a soft delete.
	// Otherwise, it issues a DELETE statement.
	db.Delete(&User{}, id)
}

Chaining builds the query. Terminal methods execute it.

Associations and the N+1 problem

Real applications have relationships. A user has many posts. A post belongs to a user. GORM handles these with struct fields and tags.

type Post struct {
	ID     uint
	Title  string
	UserID uint
	User   User `gorm:"foreignKey:UserID"`
}

type User struct {
	ID    uint
	Name  string
	Posts []Post `gorm:"foreignKey:UserID"`
}

The foreignKey tag tells GORM which column links the tables. The User field in Post is a belongs-to association. The Posts slice in User is a has-many association.

When you fetch a user, GORM does not fetch the posts automatically. This is by design. Fetching related data can be expensive. If you need the posts, you must ask for them using Preload.

func getUserWithPosts(db *gorm.DB, id uint) {
	var user User

	// Preload tells GORM to fetch the associated Posts.
	// It runs a second query to get all posts for the user.
	// This avoids the N+1 problem.
	db.Preload("Posts").First(&user, id)

	for _, post := range user.Posts {
		println("Post:", post.Title)
	}
}

If you skip Preload and loop through users to access their posts, GORM will run a query for each user. This is the N+1 problem. You run one query to get N users, then N queries to get their posts. The database gets hammered. Preload fixes this by fetching all related records in a single additional query.

Preload or pay the N+1 tax.

Transactions

Database operations should be atomic. Either all changes happen, or none do. GORM provides a Transaction method that handles commit and rollback logic.

func transferMoney(db *gorm.DB, fromID, toID uint, amount float64) error {
	// Transaction takes a function.
	// The function receives a *gorm.DB scoped to the transaction.
	// If the function returns an error, GORM rolls back.
	// If it returns nil, GORM commits.
	return db.Transaction(func(tx *gorm.DB) error {
		// Deduct from sender
		if err := tx.Model(&Account{}).Where("id = ?", fromID).Update("balance", gorm.Expr("balance - ?", amount)).Error; err != nil {
			return err
		}

		// Add to receiver
		if err := tx.Model(&Account{}).Where("id = ?", toID).Update("balance", gorm.Expr("balance + ?", amount)).Error; err != nil {
			return err
		}

		return nil
	})
}

The Transaction method starts a transaction. It passes a transaction-scoped *gorm.DB to your function. All operations inside the function use this scope. If your function returns an error, GORM calls ROLLBACK. If it returns nil, GORM calls COMMIT. This pattern keeps transaction logic clean and prevents accidental commits.

Transactions are plumbing. Run them through every multi-step mutation.

Pitfalls and compiler errors

GORM hides SQL, but it does not hide database rules. You still need to understand constraints, indexes, and query performance.

One common issue is ignoring errors. GORM methods return a *gorm.DB. The error is stored in the Error field. If you chain methods and ignore the error, you might operate on a failed query.

// BAD: Ignoring the error
db.Where("name = ?", "Alice").First(&user)

// GOOD: Checking the error
err := db.Where("name = ?", "Alice").First(&user).Error
if err != nil {
	// Handle error
}

Another pitfall is AutoMigrate limitations. It does not drop columns. If you remove a field from your struct, the column stays in the database. If you rename a field, GORM creates a new column and leaves the old one. Over time, your database accumulates ghost columns. You need to clean these up manually or with a migration tool.

GORM also generates queries that are not always optimal. Complex joins or aggregations can be verbose in GORM. If the generated SQL is slow, you can switch to raw SQL using db.Raw.

var results []map[string]interface{}
// Raw executes a raw SQL string.
// Use it when GORM's chaining is too limited or slow.
db.Raw("SELECT name, COUNT(*) as count FROM users GROUP BY name").Scan(&results)

The compiler rejects your code with undefined: gorm if you forget to import the package. The compiler complains with cannot use x as string value in argument if you pass the wrong type to a query parameter. GORM uses ? placeholders for parameters. You must pass the values as arguments.

// BAD: String concatenation is unsafe and wrong
db.Where("name = '" + name + "'")

// GOOD: Parameterized query
db.Where("name = ?", name)

GORM writes SQL. You write Go. Trust the struct, not the magic.

When to use GORM

GORM is a powerful tool, but it is not the right choice for every project. Use GORM when you need rapid prototyping and standard CRUD operations. Use GORM when your team knows Go but not advanced SQL, and you want type safety for database queries. Use GORM when you are building an application with standard relationships and can manage the N+1 problem with Preload.

Reach for database/sql when you need precise control over query plans and performance. Reach for database/sql when your queries involve complex joins, window functions, or database-specific features that GORM does not support well. Reach for raw SQL via db.Raw when a specific query is too complex to express with GORM's chaining methods.

Use GORM when you value developer speed and standard patterns. Use database/sql when you value query control and performance. Use raw SQL when the query is too complex for the ORM.

Where to go next