Library / SDK
graph-gophers/dataloader avatar
graph-gophers/dataloader

dataloader: batching and caching the N+1 problem away in Go

Implementation of Facebook's DataLoader in Golang

1,310 stars80 forksGoMIT

At a glance

What is it?
graph-gophers/dataloader is a Go implementation of Facebook's DataLoader, built on generics so the key and value types are fixed when you create the loader. Each Load returns a thunk that blocks until the batch resolves, there is a deliberately minimal cache for per-request loaders, and the package carries both OpenTelemetry and OpenTracing instrumentation.
Who is it for?
graph-gophers/dataloader fits a Go GraphQL or aggregation service that has a batching problem, especially one already using generics, since the loader fixes the key and value types at construction and the batch function receives a typed slice of keys.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 165 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on November 7, 2026, and from our analysis. They are not legal advice.

From daily snapshots

Star history

  • graph-gophers/dataloader1,310 ★ · +1
Star history
Repository2026-10-202026-11-07
graph-gophers/dataloader1.3k1.3k

1 Stars in the last 18 days, measured from the daily GitHub snapshot. Compare with other projects

Editorial analysis

The problem is one query per field

This is an implementation of Facebook's DataLoader in Golang, and if you have never met DataLoader the problem it solves is easy to state.

A GraphQL response asks for nested data, and a naive resolver fetches each nested object with its own query. A list of two hundred posts, each with an author, is two hundred author queries. The number of round trips grows with the size of the response rather than with the depth of the query, and the database sees it immediately.

DataLoader fixes that by collecting the keys. Instead of asking for one author, you ask the loader for an author and receive a thunk, a function that will block until the answer is ready. The loader collects the keys requested within a batch window, hands them to your batch function as a slice, and then resolves every thunk that asked for one of those keys.

The batch function therefore becomes the only place that talks to the database, and it gets to decide the shape of the query, which is where the real win is. A batch function that receives a hundred author ids can ask for those hundred rows in one statement, or it can group them by tenant, or cache aggressively, or route them to different backends, and none of that is possible if each resolver makes its own call.

The implementation is small, which is a virtue for something that sits in the hot path of every resolver. The package root has dataloader.go for the loader itself, cache.go for the cache interface, in_memory_cache.go for the default implementation and trace.go for the instrumentation, with a trace/ directory underneath for the tracing backends.

Generics fix the key and the value at construction

The usage example in the README shows the shape of the batch function, and the type signature is where the generics show up.

go
batchFn := func(ctx context.Context, keys []int) []*dataloader.Result[*User] {
  var results []*dataloader.Result[*User]
  // do some async work to get data for specified keys
  // append to this list resolved values
  return results
}

The comment above it is explicit that this function is the setup batch function, that the first Context passed to the Loader's Load function will be provided when the batch function is called, and that the function is registered with the Loader with the key and value fixed using generics.

So the key type is int, the value type is *User, and both are baked into the loader's type. The slice of keys arrives in one call, and the slice of results must line up with it, one entry per key, in order. A key that could not be loaded is represented by a Result carrying an error rather than a nil value, which is what lets a single missing row fail one field without failing the whole batch.

This is the point of the generics design. The untyped version of a DataLoader takes interface{} keys and interface{} values, and the type error surfaces as a panic deep inside a resolver, at runtime, in production. Here the compiler checks that your batch function returns the right type, and the mismatch between the key you ask for and the key you look up is a compile error rather than a wrong answer.

The loader itself is created with dataloader.NewBatchedLoader(batchFn), and the type parameters are inferred from the function you pass, so there is no separate type argument list to keep in sync.

A thunk is a promise you can wait on

The usage example then creates a loader, asks for a key, and waits for the answer.

go
loader := dataloader.NewBatchedLoader(batchFn)

thunk := loader.Load(context.TODO(), 5)
result, err := thunk()

The README explains the concept in a comment. A thunk is a function returned from a function that is a closure over a value, in this case an interface value and an error, and when it is called it will block until the value is resolved.

Two properties of this design are worth spelling out, because they are what make DataLoader safe in a concurrent server.

First, loader.Load() may be called multiple times for a given batch window. Several resolvers running in parallel for the same request, or several requests in flight at once if the loader is shared, will all land in the same batch, and the batching function is called once for the whole group. The moment a thunk is created, the key is registered, and it is the batch window that decides when the call happens.

Second, the first context passed to Load is the object that will be passed to the batch function. So the context that reaches your database call is the first one, not the most recent one and not a merge. That is a deliberate simplification, and it means the context your batch function receives carries the values of whichever request happened to load a key first, so anything you read from it has to be safe to read across requests. If you need per-request values, the convention is to put them in the batch function's own closure rather than relying on the context.

The error is returned separately from the value, so a caller can distinguish a failed load from a missing one.

The cache is deliberately weak, and the documentation says so

The cache section is the most useful paragraph in the README, because it sets an expectation that a lot of DataLoader users get wrong.

This implementation contains a very basic cache that is intended only to be used for short lived DataLoaders, meaning DataLoaders that only exist for the life of an http request. You may use your own implementation if you want.

The reason is that the cache lifetime is a policy decision, not a technical one, and the library refuses to make it for you. A loader created per request caches for that request, which is exactly right for a GraphQL server where a request may ask for the same author in several places. A loader created once at application scope caches for the lifetime of the process, which is a different and much more dangerous decision, because a stale entity persists and because the memory grows without bound.

The package therefore exposes the cache as an interface, and the repository has the pieces to show the options. cache.go defines the interface, in_memory_cache.go is the default implementation, and the example files in the package root include an example with a no cache, an example with a Go LRU cache and an example with a custom cache.

There is also a NoCache type that implements the cache interface but whose methods are all noop, for the case where you do not wish to cache anything. That is the third option, and it is a legitimate one when the batch function already does its own batching at the query level, or when the underlying data is cheap and short lived.

Tracing hooks for both generations of OpenTelemetry and OpenTracing

The package has a trace.go file and a trace/ directory, and the module dependencies explain what that means.

The direct requirements are hashicorp/golang-lru at 0.5.4 for the LRU cache example, patrickmn/go-cache for a time based cache, opentracing/opentracing-go at 1.2.0, and two OpenTelemetry modules, go.opentelemetry.io/otel and go.opentelemetry.io/otel/trace, both at 1.43.0.

Carrying both tracing generations is a pragmatic choice rather than an odd one. OpenTracing is the older interface and a lot of existing Go services still have it wired in, while OpenTelemetry is where the ecosystem has settled, and a library that only supported the newer one would be unusable in the older services that most need batching. A service that already has a tracer can hand it to the loader, and the loader then creates a span per batch, which is the span that matters, because a batch of a hundred keys is one database round trip and one span, not a hundred.

The examples in the repository root, including the LRU cache example and the no cache example, sit next to the test file, and the repository has a codecov configuration and a Go workflow badge, so the package is measured for coverage. There are also two documents at the top level, TRACE.md for the tracing integration and MIGRATE.md for moving between versions, which is where the details live for anyone wiring either up.

Version 7, the context requirement, and the v1 escape hatch

The version history explains a decision that would otherwise look arbitrary.

The module path is github.com/graph-gophers/dataloader/v7, and the install instruction in the README is a single command.

bash
go get -u github.com/graph-gophers/dataloader/v7

The v7 series takes a context.Context on every Load call and passes the first one to the batch function, which is the modern Go convention and the reason the version number exists. The README handles the objection directly with a short section titled Don't need/want to use context, in which the answer is that you are welcome to install the v1 version of the library.

That is an honest versioning story. The v1 line still exists for code that predates the context convention, and the major version bump is the semantic versioning mechanism for a breaking change to a function signature, which in Go is a real break because the type of Load changed.

The module requires Go 1.25.0, and the release history is steady: v7.2.0 on 2026-05-27, v7.1.3 on 2026-01-14 and v7.1.2 on 2025-09-09. The repository is not archived and the last push was on 2026-05-27, the same day as the v7.2.0 tag, so the library is being maintained.

The licence is MIT, there is no homepage beyond the package documentation, and the package is used well beyond GraphQL. The library implements a general batching and caching primitive, and the same loader that resolves a list of authors can batch any set of keys, which is why it shows up in aggregation services and internal APIs that fan out to several backends.

Editorial conclusion

graph-gophers/dataloader fits a Go GraphQL or aggregation service that has a batching problem, especially one already using generics, since the loader fixes the key and value types at construction and the batch function receives a typed slice of keys. It does not fit a codebase that needs a durable cross-request cache, because the built-in cache is explicitly for loaders that live for one request, and it does not fit code that avoids context, which is the reason the v1 line still exists. Verify three things before you adopt it: that your batch function is genuinely safe to call with keys collected from several requests at once, since a single batch window can mix load calls, that you choose the right cache for the lifetime of your loader, because the in-memory cache is per request and the NoCache type disables it entirely, and that the tracing hook you enable is the one your pipeline already understands, since the package carries both OpenTelemetry and OpenTracing support.

Frequently asked questions

How do I install the Go dataloader package?

Run go get -u github.com/graph-gophers/dataloader/v7, which pulls the v7 module, whose path is github.com/graph-gophers/dataloader/v7 and which requires Go 1.25.0. The README also keeps the older v1 line available for code that does not use context.

What is a thunk in the dataloader API?

It is a function returned by Load that is a closure over a value and an error, and calling it blocks until the value is resolved. Load may be called several times within one batch window, and the keys from all of those calls are handed to the batch function together.

Which context does my batch function receive?

The first context passed to Load is the object that is passed to the batch function. It is the first one rather than the most recent, so the context your database call sees carries the values of whichever request loaded a key first, and per-request values are better placed in the batch function's own closure.

Can I use my own cache with the dataloader?

Yes. The built-in cache is described as very basic and intended only for short lived DataLoaders that exist for the life of an http request, and you may use your own implementation. The package includes a NoCache type whose methods are all noop, plus examples using a Go LRU cache and a custom cache.

Does the dataloader support tracing?

Yes. The package has a trace.go file and a trace/ directory, and the module depends on both opentracing/opentracing-go and the OpenTelemetry modules otel and otel/trace. The tracing documentation is in TRACE.md, and migration notes for moving between versions are in MIGRATE.md.

Official sources

  1. graph-gophers/dataloader on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/graph-gophers-dataloader.svg)](https://hysenlabs.com/projects/graph-gophers-dataloader)

Star history chart for your README

markdown
[![Star history](https://hysenlabs.com/chart/graph-gophers-dataloader.svg)](https://hysenlabs.com/projects/graph-gophers-dataloader)

Was this useful?