The N+1 Problem, Restated

An Product object that renders an API resource might read properties like id and name directly from the model, but a field such as owner_email has to traverse an association — product.owner — which a data framework like ActiveRecord will lazily load. Rendering ten products in a naive loop produces one query for the products and ten more as each owner is fetched lazily. That's the classic N+1: one base fetch plus N per-item loads.

This is arguably the second most common cause of slow web applications, right after missing indexes. It's easy to introduce and surprisingly hard to guard against with default framework behavior.

From N+1 to Snowballing Complexity

Real systems rarely stop at a simple N+1. Once a resource has subresources with their own associations, the pattern compounds into N*M+1 queries. Models with many relations, each with further nested subresources, can push a single API request or page render into hundreds — even thousands — of database round trips.

Eager loading features in ORMs like ActiveRecord can prefetch these relations, but they only help if applied correctly. In a large codebase, it's rarely obvious whether a given code path loads the right data. You can forget an eager load entirely or place it at the wrong layer, and the failure only shows up at runtime under load.

A Creative Fix: Fibers and Intent Batching

At an architecture dominated by point lookups against a document store, there were no meaningful efficiency gains to find from joins or eager-loading strategies. So code was written as a mass of WHERE id = @id queries. Many made it to production, and a single API request would casually issue dozens of queries — all acceptable while each was 1-2 ms and hardware was fast. The trouble is those 50 calls become 1,000 over the years, and no single fix addresses the sprawl. The code was far beyond refactoring into a single elegant abstraction.

One creative solution that did work in that environment came from Ruby's fibers — lightweight coroutines with ~4 kB stacks that can be paused and resumed. Every custom #render_* override executed within a fiber. The scheme:

  • If a fiber hit the database layer, it paused and recorded its query “intent.”
  • After all fibers were either idle or complete, their intents were aggregated into batch operations — e.g., a set of user_id = ? lookups became a single user_id IN (?, ...).
  • Batch results were disaggregated and fed back to each waiting fiber.
  • Each fiber resumed as if it had received a point-load result. Any new database access restarted the cycle.

The system only aggregated point loads; it couldn't handle complex queries. But it demonstrably worked, cutting out most of the latency without requiring a rewrite of millions of lines of imperfect code.

Static Guardrails: Rails Strict Loading

Rails 6.1 introduced a pragmatic guardrail: strict loading, which turns lazy loading into an error. Rather than relying on an ORM to probably do the right thing, this forces developers to explicitly prefetch or fail fast. It won't catch everything if test coverage is shallow, but it's a meaningful safety net for preventing N+1s in typical Active Record usage.

Go, Verbosity, and the N+1 Trouble

Go presents a unique challenge: it not only inherits the N+1 problem but adds a layer of exceptional boilerplate when handling database operations. You can't build a good ORM here because any serious attempt would rely on untyped any and defeat the language's static-typing advantages. Instead of abstraction, the language pushes you to compose queries by hand, with an if err != nil { ... } block after nearly every step.

Larger applications with many associations accumulate a large amount of verbose code to do what would be a short amount in a dynamic-typed, ORM-heavy language. And despite all that verbosity, N+1s are still easy to introduce — the classic query-inside-a-loop is just as easy to write in Go as it is elsewhere. The fix is more involved when you must manually write batch queries and merge the results into a slice of objects.

Even before Go 1.18 added generics, mapping a slice into a keyed map was a manual undertaking, making simple lookup-by-id patterns far more verbose. As queries get tucked into helper functions, they become harder to spot and harder to refactor.

The point stands: verbosity does not protect you from N+1s — it just makes the fix more painful.

The Two-Phase Load and Render Pattern

The two-phase load and render pattern doesn't eliminate N+1s outright, but it makes introducing one harder than avoiding it. The key constraint is that rendering code never touches the database. Instead, each resource type is responsible for a load phase that fetches everything needed to render an arbitrary number of resources into a load bundle, and a render phase that consumes that bundle to produce a single API resource.

Rendering a load bundle.

Consider a product API resource where each product has one admin and belongs to a team. The load phase fetches owner and team records in bulk for all products—via sqlc-generated queries like AccountGetByIDMany, which maps to roughly SELECT * FROM account WHERE id = any(@id::uuid[])—and stores them in maps on the load bundle keyed by ID. The render phase then maps direct properties like ID and Name straight from the database model, while indirect properties like OwnerEmail and TeamName are pulled from the records already loaded in the bundle.

package apiresourcekind

type Product struct {
    apiresource.APIResourceBase

    ID         uuid.UUID `json:"id"`
    Name       string    `json:"name"`
    OwnerID    uuid.UUID `json:"owner_id"`
    OwnerEmail string    `json:"owner_email"`
    TeamID     uuid.UUID `json:"team_id"`
    TeamName   string    `json:"team_email"`
}
//
// Phase 1: Load data into a bundle
//

type ProductLoadBundle struct {
    accounts map[uuid.UUID]*dbsqlc.Account // account ID -> account
    teams    map[uuid.UUID]*dbsqlc.Team    // team ID -> team
}

func (_ *Product) LoadBundle(
    ctx context.Context, e db.Executor, baseParams *pbaseparam.BaseParams, products []*dbsqlc.Product
) (*ProductLoadBundle, error) {
    var (
        bundle  = &ProductLoadBundle{}
        queries = dbsqlc.New(e)
    )

    // Load owners for all products, map them in bundle by ID.
    {
        accounts, err := queries.AccountGetByIDMany(ctx,
            sliceutil.Map(products, func(p *dbsqlc.Product) uuid.UUID { return p.OwnerID }))
        if err != nil {
            return nil, xerrors.Errorf("error getting accounts: %w", err)
        }
        bundle.accounts = sliceutil.KeyBy(accounts, func(a *dbsqlc.Account) uuid.UUID { return a.ID })
    }

    // Load teams for all products, map them in bundle by ID.
    {
        teams, err := queries.TeamGetByIDMany(ctx,
            sliceutil.Map(products, func(p *dbsqlc.Product) uuid.UUID { return p.TeamID }))
        if err != nil {
            return nil, xerrors.Errorf("error getting teams: %w", err)
        }
        bundle.teams = sliceutil.KeyBy(teams, func(t *dbsqlc.Team) uuid.UUID { return t.ID })
    }

    return bundle, nil
}
Product load bundle.
//
// Phase 2: Use a bundle to render a single resource
//

func (_ *Product) Render(
    ctx context.Context, baseParams *pbaseparam.BaseParams, bundle *ProductLoadBundle, product *dbsqlc.Product
) (*Product, error) {
    return &Product{
        ID:         product.ID,
        Name:       product.Name,
        OwnerID:    product.OwnerID,
        OwnerEmail: bundle.accounts[product.OwnerID].Email,
        TeamID:     product.TeamID,
        TeamName:   bundle.teams[product.TeamID].Name,
    }, nil
}

The full render pipeline is:

  1. LoadBundle is invoked once, regardless of how many products are being rendered.
  2. Render is invoked once per product, reusing the same load bundle.

Renderable

Once a resource implements the full two-phase pattern, rendering it anywhere else becomes straightforward. The Renderable interface holds the bundle, model, and API resource types:

resource, err := apiresource.Render[*apiresourcekind.Product](
    ctx, tx, svc.BaseParams, product
)
if err != nil {
    return nil, err
}
resources, err := apiresource.RenderMany[*apiresourcekind.Product](
    ctx, tx, svc.BaseParams, products
)
if err != nil {
    return nil, err
}
package apiresource

// Renderable is an API resource that can be rendered by Render or RenderMany.
type Renderable[TLoadBundle any, TModel any, TResource any] interface {
    // LoadBundle loads a load bundle for the given models, usually from a
    // database, which can then be used along with a model to render a full API
    // resource.
    //
    // It may seem odd that this takes a slice of models instead of a model, but
    // this is for a good reason: it lets us batch load all data dependencies
    // all at once instead of loading them one-by-one, causing an N+1 problem.
    LoadBundle(ctx context.Context, e db.Executor, baseParams *pbaseparam.BaseParams, models []TModel) (TLoadBundle, error)

    // Render renders an API resource using a load bundle and model as input.
    Render(ctx context.Context, baseParams *pbaseparam.BaseParams, bundle TLoadBundle, model TModel) (TResource, error)
}

Implementations of Render and RenderMany are then trivial—they load the bundle once and render either a single resource or a slice of them:

package apiresource

// Render renders an API resource.
//
// The type parameters may appear to be in a weird order as you might expect
// TModel before TRenderable, but it's like this for a good reason. Type
// parameters that can be inferred can be omitted, and in general use of Render
// only TRenderable needs to be included. Both TModel and TRenderable are
// inferred and should be omitted.
func Render[TRenderable Renderable[TLoadBundle, TModel, TRenderable], TLoadBundle any, TModel any](
    ctx context.Context, e db.Executor, baseParams *pbaseparam.BaseParams, model TModel,
) (TRenderable, error) {
    var renderable TRenderable

    bundle, err := renderable.LoadBundle(ctx, e, baseParams, []TModel{model})
    if err != nil {
        return renderable, xerrors.Errorf("error loading bundle: %w", err)
    }

    resource, err := renderable.Render(ctx, baseParams, bundle, model)
    if err != nil {
        return renderable, xerrors.Errorf("error rendering resource: %w", err)
    }

    return resource, nil
}

// RenderMany is similar to Render, but renders many API resources at once.
func RenderMany[TRenderable Renderable[TLoadBundle, TModel, TRenderable], TLoadBundle any, TModel any](
    ctx context.Context, e db.Executor, baseParams *pbaseparam.BaseParams, models [TModel,
) ([]TRenderable, error) {
    var renderable TRenderable

    bundle, err := renderable.LoadBundle(ctx, e, baseParams, models)
    if err != nil {
        return nil, xerrors.Errorf("error loading bundle: %w", err)
    }

    resources := make([]TRenderable, len(models))

    for i := range resources {
        resources[i], err = renderable.Render(ctx, baseParams, bundle, models[i])
        if err != nil {
            return nil, xerrors.Errorf("error rendering resource: %w", err)
        }
    }

    return resources, nil
}

A useful refinement, pointed out in a GitHub issue by Roman, is that swapping the positions of two generic parameters lets the compiler infer most of them, so Render can be called with just a single generic parameter.

Handling Nested Resources

The real test comes with subresources. If a product renders a list of Widget subresources, and widgets themselves need to load data (say, the location of the factory where they're produced), calling Render inside another Render would reintroduce N+1s. The pattern avoids this by composing load bundles—a parent resource's Load implementation also calls Load for its subresources, guaranteeing only one Load per resource type.

package apiresourcekind

type Widget struct {
	apiresource.APIResourceBase

	ID              uuid.UUID `json:"id"`
	FactoryID       uuid.UUID `json:"factory_id"`
	FactoryLocation string    `json:"factory_location"`
	Name            string    `json:"name"`
}

//
// Renderable implementation
//

type WidgetLoadBundle struct {
	factories map[uuid.UUID]*dbsqlc.Factory // factory ID -> factory
}

func (_ *Widget) LoadBundle(ctx context.Context, e db.Executor, baseParams *pbaseparam.BaseParams, widgets []*dbsqlc.Widget) (*WidgetLoadBundle, error) {
	var (
		bundle  = &WidgetLoadBundle{}
		queries = dbsqlc.New(e)
	)

	// Load factories for all widgets, map them in bundle by ID.
	{
		factories, err := queries.FactoryGetByIDMany(ctx,
			sliceutil.Map(widgets, func(w *dbsqlc.Widget) uuid.UUID { return w.FactoryID }))
		if err != nil {
			return nil, xerrors.Errorf("error getting factories: %w", err)
		}
		bundle.factories = sliceutil.KeyBy(factories, func(f *dbsqlc.Factory) uuid.UUID { return f.ID })
	}

	return bundle, nil
}

func (_ *Widget) Render(ctx context.Context, baseParams *pbaseparam.BaseParams, bundle *WidgetLoadBundle, widget *dbsqlc.Widget) (*Widget, error) {
	return &Widget{
		ID:              widget.ID,
		FactoryID:       widget.FactoryID,
		FactoryLocation: bundle.factories[widget.FactoryID].Location,
		Name:            widget.Name,
	}, nil
}
Product load bundle with internalized widget load bundle.

In practice, WidgetLoadBundle is embedded on ProductLoadBundle and populated during Load. Product's Render then calls Render for each embedded widget, passing through the shared bundle:

package apiresourcekind

type Product struct {
	apiresource.APIResourceBase

	ID         uuid.UUID `json:"id"`
	Name       string    `json:"name"`
	OwnerID    uuid.UUID `json:"owner_id"`
	OwnerEmail string    `json:"owner_email"`
	TeamID     uuid.UUID `json:"team_id"`
	TeamName   string    `json:"team_email"`
	Widgets    []*Widget `json:"widget"`     // NEW!!
}

//
// Renderable implementation
//

type ProductLoadBundle struct {
	accounts     map[uuid.UUID]*dbsqlc.Account  // account ID -> account
	teams        map[uuid.UUID]*dbsqlc.Team     // team ID -> team
	widgetBundle *WidgetLoadBundle              // <-- the product load bundle has a widget load bundle!
	widgets      map[uuid.UUID][]*dbsqlc.Widget // product ID -> widgets
}

func (_ *Product) LoadBundle(ctx context.Context, e db.Executor, baseParams *pbaseparam.BaseParams, products []*dbsqlc.Product) (*ProductLoadBundle, error) {
	var (
		bundle  = &ProductLoadBundle{}
		queries = dbsqlc.New(e)
	)

    ...

	// Load widgets for all products, group them in bundle by product ID, and load widget bundle.
	{
		widgets, err := queries.WidgetGetByProductIDMany(ctx,
			sliceutil.Map(products, func(p *dbsqlc.Product) uuid.UUID { return p.ID }))
		if err != nil {
			return nil, xerrors.Errorf("error getting widgets: %w", err)
		}
		bundle.widgets = sliceutil.GroupBy(widgets, func(w *dbsqlc.Widget) uuid.UUID { return w.ProductID })

		bundle.widgetBundle, err = (&Widget{}).LoadBundle(ctx, e, baseParams, widgets)
		if err != nil {
			return nil, err
		}
	}

	return bundle, nil
}

func (_ *Product) Render(ctx context.Context, baseParams *pbaseparam.BaseParams, bundle *ProductLoadBundle, product *dbsqlc.Product) (*Product, error) {
	// Render widget subresources.
	var widgetResources []*Widget
	if widgets, ok := bundle.widgets[product.ID]; ok {
		widgetResources := make([]*Widget, len(widgets))
		for i, widget := range widgets {
			var err error
			widgetResources[i], err = (&Widget{}).Render(ctx, baseParams, bundle.widgetBundle, widget)
			if err != nil {
				return nil, err
			}
		}
	}

	return &Product{
		ID:         product.ID,
		Name:       product.Name,
		OwnerID:    product.OwnerID,
		OwnerEmail: bundle.accounts[product.OwnerID].Email,
		TeamID:     product.TeamID,
		TeamName:   bundle.teams[product.TeamID].Name,
		Widgets:    widgetResources,
	}, nil
}

The approach scales to arbitrary depth. Load bundles map 1:1:1, and no matter how many resources or how deep the hierarchy, the number of database operations stays constant. Predictable performance is maintained by construction.

Beyond Go

Go's verbosity and lack of dynamic features largely motivated this design—a framework for basic data loading would have been necessary regardless. Rails' strict loading feature is somewhat unusual in that most ORMs with lazy-loading APIs offer no such guardrails, making N+1s the default and forcing developers to whack-a-mole performance hotspots one at a time.

The two-phase pattern is language-agnostic in spirit. The Go syntax looks dense, but the core idea reduces to a few plain structs, one interface, and two functions. In a less verbose language, the same structure would require roughly half the code. The implementation shown here is meant as a reference, not a package prescription—but it's easy enough to reproduce in any codebase that needs to keep N+1s at bay.