Associations and Eager Loading in Go — Without GORM

The difference between a query builder and an ORM is relationships. Neat ORM handles five association types with eager and lazy loading, solves the N+1 problem, and keeps relationships in sync with the migration system — all without GORM.

Associations and Eager Loading in Go — Without GORM

Associations and Eager Loading in Go — Without GORM

The difference between a query builder and an ORM is relationships. A query builder helps you write SQL. An ORM helps you work with related data.

If you have a blog, a post has an author, a post has comments, each comment has an author. When you load a post, you often want all of this. Without an ORM, you write four queries and glue the results together in Go. With a good ORM, you write one line.

Neat ORM handles five types of relationships. Let me explain what they are and how they work.


The Five Types

There are really only two fundamental relationships: "belongs to" and "has many." Everything else is a variation.

BelongsTo: The child has a foreign key pointing to the parent. A comment has post_id. This is the simplest relationship — the child knows who its parent is.

HasMany: The parent has multiple children. A post has many comments. The foreign key is still on the child (post_id), but you access the relationship from the parent side.

HasOne: A special case of HasMany where there's only one child. A user has one profile. Same mechanism, different cardinality.

PolymorphicBelongsTo: A child that can belong to different types of parents. An image can belong to a post, a user, or a product. Instead of three nullable foreign keys, you have two columns: a type ("Post", "User", "Product") and an ID. This is borrowed from Laravel, and it's the cleanest solution to a common problem.

PolymorphicHasMany: The parent side of a polymorphic relationship. A post has many images, a user has many images, all in the same table.


Eager Loading

The killer feature of any ORM is eager loading. Here's the problem it solves:

// Without eager loading: N+1 queries
posts, _ := db.Query().Get(&posts)
for i := range posts {
    db.Query().Where("post_id", posts[i].ID).Get(&posts[i].Comments)
}

If you have 100 posts, that's 101 queries. One for the posts, one for each post's comments. This is the N+1 problem, and it's the most common performance issue in ORM-based applications.

With eager loading:

// With eager loading: 2 queries
db.Query().With("Comments").Get(&posts)

Two queries. One for posts, one for comments (WHERE post_id IN (1, 2, 3, ..., 100)). The ORM maps comments to posts in memory. Same data, 50x fewer queries.

The mechanism is straightforward but worth understanding:

  1. Query the parent: SELECT * FROM posts WHERE ... → returns N posts.
  2. Collect IDs: Extract the primary key from each post: [1, 2, 3, ..., N].
  3. Query the children: SELECT * FROM comments WHERE post_id IN (1, 2, 3, ..., N) → returns M comments.
  4. Map in memory: For each comment, find its parent post by post_id and append to post.Comments.

The IN clause is the key. Instead of N separate queries, you do one query with N values in the WHERE clause. Most databases handle IN clauses efficiently, even with hundreds of values.

You can load multiple relationships:

db.Query().With("Comments").With("Author").With("Tags").Get(&posts)

Four queries total. One per model. Everything mapped in memory.


Lazy Loading

Sometimes you don't know whether you'll need a relationship. Maybe you're rendering a template that conditionally shows comments. Maybe you're building an API that includes relationships only for certain users.

For those cases, Neat supports lazy loading:

db.Query().Where("id", 1).First(&post)
// post.Comments is not loaded

if shouldShowComments {
    db.Query().Load(&post, "Comments")
    // Now post.Comments is loaded
}

Lazy loading gives you flexibility. Eager loading gives you performance. Use the right one for the situation.


Polymorphic Associations

Here's a problem that sounds niche but isn't: you have an Image model. Images can be attached to posts, users, or products. How do you model this?

Option 1: Separate foreign keys. post_id, user_id, product_id. Two are always null. Ugly.

Option 2: Separate tables. post_images, user_images, product_images. Same data, three tables. Redundant.

Option 3: Polymorphic association. Two columns: imageable_type and imageable_id. The type column stores the parent model name. The ID column stores the parent's primary key. One table, no nulls.

type Image struct {
    ID             int64
    URL            string
    ImageableType  string  // "Post", "User", or "Product"
    ImageableID    int64
}

Loading a post's images:

db.Query().With("Images").Where("id", 1).First(&post)
// SELECT * FROM images WHERE imageable_type = 'Post' AND imageable_id IN (1)

Loading a user's images:

db.Query().With("Images").Where("id", 1).First(&user)
// SELECT * FROM images WHERE imageable_type = 'User' AND imageable_id IN (1)

Same table, different type filter. This is the cleanest solution, and Neat supports it natively.


Field Resolution

For the ORM to load a relationship, it needs to know which fields to join on. For a BelongsTo (Comment → Post), it needs the foreign key on the child (post_id) and the primary key on the parent (id). For a HasMany (Post → Comments), it's reversed — the primary key is on the parent, the foreign key is on the child.

Neat resolves these automatically using struct tags and naming conventions. If your schema follows conventions (post_id for the foreign key, id for the primary key), it just works. If it doesn't, you can specify the fields explicitly.


Why Not Just Use GORM?

GORM has associations. It has eager loading. It has polymorphic relationships. So why did I build my own?

Because GORM's associations are tightly coupled to GORM's model system. You have to use GORM's struct tags, GORM's model conventions, GORM's relationship resolution. If any of those don't fit your project, you're fighting the framework.

Neat's associations are built on the same contract pattern as the rest of the ORM. The association types are interfaces. The field resolution is configurable. The eager loading mechanism is transparent — you can see exactly which queries it runs by enabling debug mode.

And — this matters — Neat's associations work alongside the migration system. You define your relationships in Go, you define your schema in migrations, and they stay in sync. GORM's auto-migrate approach doesn't give you that guarantee.


The Result

Five relationship types. Two loading strategies. One API:

db.Query().With("Comments").With("Author").Where("id", 1).First(&post)

No GORM. No code generation. No DSL. Just Go structs, struct tags, and a fluent query builder that knows how to load relationships efficiently.

That's what an ORM should do. Not just build queries, but handle the relationships between your data.

Previous
Soft Deletes Done Right — Three Strategies for Go
Next
Back to Golang: Why I Switched From Static to Dynamic Again

Keep Reading

Explore more articles and insights

Back to Golang: Why I Switched From Static to Dynamic Again

Back to Golang: Why I Switched From Static to Dynamic Again

Read Article
Soft Deletes Done Right — Three Strategies for Go

Soft Deletes Done Right — Three Strategies for Go

Read Article
Building 7 Database Drivers — The Compatibility Illusion and Its Discontents

Building 7 Database Drivers — The Compatibility Illusion and Its Discontents

Read Article
View All Blog Posts