python 110 lines · 3 tabs

Batched DataLoader to Avoid N+1 Queries in FastAPI Responses

Shared by codesnips Sep 2026
3 tabs
from typing import List, Optional

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from .dataloader import DataLoader
from .models import User


def build_user_loader(session: AsyncSession) -> DataLoader:
    async def load_users_by_ids(ids: List[int]) -> List[Optional[User]]:
        result = await session.execute(
            select(User).where(User.id.in_(ids))
        )
        by_id = {user.id: user for user in result.scalars().all()}
        # Preserve the requested order; None for ids with no matching row.
        return [by_id.get(uid) for uid in ids]

    return DataLoader(load_users_by_ids)
3 files · python Explain with highlit

Serializing a list of resources that each reference a related record is a classic source of N+1 queries: rendering 50 comments triggers 50 separate author lookups. The DataLoader pattern fixes this by collecting the individual key requests made during one response, coalescing them into a single batched query, and handing each caller back only the row it asked for. This snippet wires that pattern into an async FastAPI endpoint so the author of each comment is joined in with exactly one extra query.

In dataloader.py, DataLoader buffers keys instead of querying immediately. Each call to load appends the key to _queue, stores an asyncio.Future, and — crucially — schedules _dispatch with loop.call_soon only once per pending batch. Because call_soon runs after the current synchronous chunk of the event loop yields, every load issued while serializing the response lands in the same queue before _dispatch fires. _dispatch deduplicates keys, calls the user-supplied batch_fn a single time, then resolves each future with its matching value or None. The _scheduled flag guards against dispatching twice, and results are cached in _cache so repeated keys within a request are free.

In loaders.py, load_users_by_ids is the concrete batch_fn: it runs one SELECT ... WHERE users.id IN (...), builds an id-to-row map, and returns values ordered to match the requested keys. Returning None for missing ids keeps the loader contract total, so a caller never hangs waiting on a key that has no row. build_user_loader creates a fresh loader per request, which matters because the cache and in-flight batch must not leak between users.

In routes.py, list_comments fetches the comments, then awaits user_loader.load(...) for each one. asyncio.gather lets those loads run concurrently while the loader still collapses them into one query. The per-request loader is created inside the dependency get_user_loader so its lifecycle matches the request. The trade-off is added indirection and a reliance on event-loop timing, but it removes the N+1 without threading join logic through every query. It shines when the related resource is reused across many parents or reached through several code paths.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
ruby
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 performance streaming
by codesnips 3 tabs
javascript
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
  const success = true;

  setTimeout(() => {
    if (success) {

Promises and async/await patterns for asynchronous JavaScript

javascript promises async-await
by Alex Chang 1 tab
ruby
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

rails performance activerecord
by Alex Kumar 2 tabs
typescript
export type Settled<R> =
  | { status: 'fulfilled'; value: R }
  | { status: 'rejected'; reason: unknown };

export interface ConcurrencyOptions {
  limit: number;

Simple concurrency limiter for batch operations

node concurrency async
by codesnips 2 tabs
ruby
json.array! @posts do |post|
  json.cache! ['v1', post], expires_in: 1.hour do
    json.id post.id
    json.title post.title
    json.excerpt post.excerpt
    json.published_at post.published_at

Fragment caching for expensive JSON serialization

rails caching performance
by Alex Kumar 1 tab

Share this code

Here's the card — post it anywhere.

Batched DataLoader to Avoid N+1 Queries in FastAPI Responses — share card
Link copied