package feed
import "time"
type Post struct {
ID int64
AuthorID int64
Title string
Body string
CreatedAt time.Time
Comments []Comment
}
type Comment struct {
ID int64
PostID int64
AuthorID int64
Body string
CreatedAt time.Time
}
package feed
import (
"context"
"database/sql"
"fmt"
"strings"
)
func buildInClause(start, n int) string {
placeholders := make([]string, n)
for i := 0; i < n; i++ {
placeholders[i] = fmt.Sprintf("$%d", start+i)
}
return strings.Join(placeholders, ", ")
}
func LoadCommentsForPosts(ctx context.Context, db *sql.DB, posts []Post) (map[int64][]Comment, error) {
ids := make([]int64, 0, len(posts))
seen := make(map[int64]struct{}, len(posts))
for _, p := range posts {
if _, ok := seen[p.ID]; ok {
continue
}
seen[p.ID] = struct{}{}
ids = append(ids, p.ID)
}
result := make(map[int64][]Comment, len(ids))
if len(ids) == 0 {
return result, nil
}
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
}
query := fmt.Sprintf(`
SELECT id, post_id, author_id, body, created_at
FROM comments
WHERE post_id IN (%s)
ORDER BY created_at ASC`, buildInClause(1, len(ids)))
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query comments: %w", err)
}
defer rows.Close()
for rows.Next() {
var c Comment
if err := rows.Scan(&c.ID, &c.PostID, &c.AuthorID, &c.Body, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("scan comment: %w", err)
}
result[c.PostID] = append(result[c.PostID], c)
}
return result, rows.Err()
}
package feed
import (
"context"
"database/sql"
"fmt"
)
type Service struct {
DB *sql.DB
}
func (s *Service) GetFeed(ctx context.Context, limit int) ([]Post, error) {
rows, err := s.DB.QueryContext(ctx, `
SELECT id, author_id, title, body, created_at
FROM posts
ORDER BY created_at DESC
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("query posts: %w", err)
}
defer rows.Close()
var posts []Post
for rows.Next() {
var p Post
if err := rows.Scan(&p.ID, &p.AuthorID, &p.Title, &p.Body, &p.CreatedAt); err != nil {
return nil, fmt.Errorf("scan post: %w", err)
}
posts = append(posts, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
commentsByPost, err := LoadCommentsForPosts(ctx, s.DB, posts)
if err != nil {
return nil, err
}
for i := range posts {
posts[i].Comments = commentsByPost[posts[i].ID]
}
return posts, nil
}
This snippet shows the classic dataloader technique for eliminating N+1 queries in Go: instead of issuing one SELECT per parent row to fetch its children, all the parent IDs are collected and their children are loaded with a single WHERE ... IN (...) query, then grouped back into the parents in memory.
In models.go, the domain types Post and Comment are defined along with a CommentsByPostID map that later holds each post's comments. Keeping the grouping structure explicit makes the assembly step obvious and avoids surprising the caller with lazy database access hidden behind a getter.
comment_loader.go contains the core of the pattern. LoadCommentsForPosts first walks the posts to build a de-duplicated slice of IDs; the seen set matters because the same author or parent could otherwise be requested twice, inflating the query and the result set. The helper buildInClause generates the positional placeholders $1, $2, ... that pgx and lib/pq expect, since Go's standard database/sql cannot expand a slice into an IN list automatically. The rows are scanned once and bucketed into a map[int64][]Comment keyed by post_id, giving O(1) attachment back onto each post. The early return on an empty ID slice avoids emitting a malformed IN () query, which is a common crash in naive implementations.
service.go wires it together: GetFeed fetches the page of posts with one query, then calls the loader exactly once. The result is two queries total regardless of page size — the defining property of this pattern — rather than 1 + N.
The main trade-off is memory and query-size: a very large ID list can exceed database parameter limits (Postgres caps bind parameters around 65535), so production code often chunks the IDs into batches. The approach also loads all children eagerly, which wastes work if the caller only needs a few. When those constraints are acceptable, batch-loading is the simplest and most predictable fix for N+1, and it composes naturally with request-scoped caching or a full dataloader library when nested relations appear.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
# Vulnerable: user input is concatenated directly into SQL.
email = params[:email]
password = params[:password]
sql = "SELECT * FROM users WHERE email = '#{email}' AND password_hash = '#{password}'"
user = ActiveRecord::Base.connection.execute(sql).first
SQL injection prevention with unsafe and safe query patterns
# BAD: N+1 query problem
@users = User.all
@users.each do |user|
puts user.posts.count # Fires query for each user!
end
ActiveRecord query optimization and N+1 prevention
Share this code
Here's the card — post it anywhere.