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:
- Query the parent:
SELECT * FROM posts WHERE ...→ returns N posts. - Collect IDs: Extract the primary key from each post:
[1, 2, 3, ..., N]. - Query the children:
SELECT * FROM comments WHERE post_id IN (1, 2, 3, ..., N)→ returns M comments. - Map in memory: For each comment, find its parent post by
post_idand append topost.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.



