Resolve N+1 Query Problem

Introduction

  • N+1 Query Problem is a performance issue that occurs when an application executes 1 initial query to fetch a list of N records and then executes N additional sub-queries to fetch related data for each record.
  • The total number of queries sent to the Database will be N + 1. As N increases (for example N = 1000), the application must execute 1001 SQL statements, causing an I/O bottleneck, increasing latency and overloading the Database.
alt text

Example

Suppose you need to display 10 products (N = 10) along with the list of variants for each product:

  • First Query (1): Get the list of 10 products: SELECT * FROM products LIMIT 10
  • Next N Queries (N = 10): Iterate through each product to get variants.
SELECT * FROM product_variants WHERE product_id = 1;
SELECT * FROM product_variants WHERE product_id = 2;
...
SELECT * FROM product_variants WHERE product_id = 10;
  • Total: 10+1=11 SQL queries. If fetching 1,000 products, the number of queries spikes to 1,001 SQL queries.

Solutions

Depending on the application architecture (standard REST API or GraphQL, ORM you use), here are the approaches to handle it:

Eager Loading with JOIN

  • Designed for traditional REST API / SQL / ORM
  • Instead of performing individual queries inside a loop, we instruct the ORM to perform a JOIN operation within a single query.
  • Mechanism: Combine data from the main table and child table at the Database level.

Grouping by Array of IDs

If you do not want to use JOIN (due to duplicate main table data in the returned results), you can gather all IDs together and run exactly 2 queries

  • Query 1: Get the list of products.
  • Query 2: Get all variants belonging to the IDs obtained in Query 1 (used with WHERE IN or EXISTS)

Using DataLoader

  • This is the standard solution for GraphQL
  • In GraphQL, because clients have the flexibility to query fields on demand, independent resolvers can easily cause N+1. DataLoader helps resolve this issue using Batching and Caching techniques within the scope of a single request.
  • Operational Mechanism:
    • When child resolvers call loader.load(productId), DataLoader does not send the query immediately but waits (ticks the Event Loop).
    • It collects all productIds within the same render cycle into an array.
    • Sends a single query containing WHERE id IN (...) to the Database.
    • Distributes the results back correctly to each resolver.

AST-Based Query Resolution

  • This is a feature of modern GraphQL Engines, frameworks or GraphQL plugins (such as @pothos/plugin-drizzle, Hasura, Prisma-GraphQL) that do not wait for child resolvers to execute. They analyze the AST (Abstract Syntax Tree) syntax of the GraphQL Query sent by the client.
  • Mechanism: Look ahead at all fields requested by the client (for example client requests products + variants) to automatically generate a single SQL JOIN or JSON_AGG query in the Database before data is returned to the app.

Detail

In the previous article I guided you on using Pothos to implement GraphQL, if you retrieve product data along with variantsalt text

When checking logs you will see the N+1 Query Problem appear as follows, for each product there is 1 query to fetch its corresponding variant

[SQL Query] Query: select "id", "created_at", "updated_at", "name", "description" from "products" order by "products"."id" asc limit $1 -- params: [6]
[SQL Query] Query: select "id", "created_at", "updated_at", "product_id", "sku", "variation_name", "price", "stock", "views" from "product_variants" where "product_variants"."product_id" = $1 -- params: [1]
[SQL Query] Query: select "id", "created_at", "updated_at", "product_id", "sku", "variation_name", "price", "stock", "views" from "product_variants" where "product_variants"."product_id" = $1 -- params: [2]
[SQL Query] Query: select "id", "created_at", "updated_at", "product_id", "sku", "variation_name", "price", "stock", "views" from "product_variants" where "product_variants"."product_id" = $1 -- params: [3]
[SQL Query] Query: select "id", "created_at", "updated_at", "product_id", "sku", "variation_name", "price", "stock", "views" from "product_variants" where "product_variants"."product_id" = $1 -- params: [4]
[SQL Query] Query: select "id", "created_at", "updated_at", "product_id", "sku", "variation_name", "price", "stock", "views" from "product_variants" where "product_variants"."product_id" = $1 -- params: [5]

Next I will guide you to resolve the N+1 Query problem as follows

Create file src/drizzle-orm/schema/schema.ts

import {relations, sql} from 'drizzle-orm'
import {
  check,
  decimal,
  integer,
  pgTable,
  serial,
  text,
  timestamp,
  unique,
  varchar,
} from 'drizzle-orm/pg-core'

export const baseColumns = {
  id: serial('id').primaryKey(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}

export const users = pgTable('users', {
  ...baseColumns,
  username: varchar('username', {length: 255}).notNull().unique(),
  email: varchar('email', {length: 255}).notNull().unique(),
  password: text('password').notNull(),
  firstName: varchar('first_name', {length: 100}).notNull(),
  lastName: varchar('last_name', {length: 100}).notNull(),
  phone: varchar('phone', {length: 20}),
})

export const products = pgTable('products', {
  ...baseColumns,
  name: varchar('name', {length: 255}).notNull(),
  description: text('description'),
})

export const productVariants = pgTable(
  'product_variants',
  {
    ...baseColumns,
    productId: integer('product_id')
      .notNull()
      .references(() => products.id, {onDelete: 'cascade'}),
    sku: varchar('sku', {length: 100}).notNull().unique(),
    variationName: varchar('variation_name', {length: 100}).notNull(),
    price: decimal('price', {precision: 12, scale: 2}).notNull(),
    stock: integer('stock').notNull().default(0),
  },
  table => [check('stock_gte_zero', sql`${table.stock} >= 0`)]
)

export const carts = pgTable('carts', {
  ...baseColumns,
  userId: integer('user_id')
    .notNull()
    .unique()
    .references(() => users.id, {onDelete: 'cascade'}),
})

export const cartItems = pgTable(
  'cart_items',
  {
    ...baseColumns,
    cartId: integer('cart_id')
      .notNull()
      .references(() => carts.id, {onDelete: 'cascade'}),
    variantId: integer('variant_id')
      .notNull()
      .references(() => productVariants.id),
    quantity: integer('quantity').default(0),
  },
  table => [
    unique('cart_items_cart_id_variant_id_unique').on(
      table.cartId,
      table.variantId
    ),
  ]
)

export const productsRelations = relations(products, ({many}) => ({
  variants: many(productVariants),
}))

export const productVariantsRelations = relations(
  productVariants,
  ({one, many}) => ({
    product: one(products, {
      fields: [productVariants.productId],
      references: [products.id],
    }),
    cartItems: many(cartItems),
  })
)

export const cartsRelations = relations(carts, ({many}) => ({
  items: many(cartItems),
}))

export const cartItemsRelations = relations(cartItems, ({one}) => ({
  cart: one(carts, {
    fields: [cartItems.cartId],
    references: [carts.id],
  }),
  variant: one(productVariants, {
    fields: [cartItems.variantId],
    references: [productVariants.id],
  }),
}))

Please note that you must create relations like productsRelations, productVariantsRelations, cartsRelations, cartItemsRelations, which are key factors to implementing AST-Based Query Resolution

Create file src/drizzle-orm/index.ts

import {DefaultLogger, type LogWriter} from 'drizzle-orm'
import {drizzle, type NodePgDatabase} from 'drizzle-orm/node-postgres'
import {Pool} from 'pg'
import {DRIZZLE_DB} from 'src/constant'
import * as schema from './schema/schema'

class CustomLogger implements LogWriter {
  write(query: string): void {
    const timestamp = new Date().toISOString()
    console.log(`\x1b[36m[SQL Query]\x1b[0m ${query}`)
  }
}

export type DrizzleDB = NodePgDatabase<typeof schema>

export const createPool = () => {
  return new Pool({connectionString: process.env.DATABASE_URL})
}

export const createDrizzle = () => {
  const pool = createPool()
  return drizzle(pool, {
    schema,
    logger: new DefaultLogger({writer: new CustomLogger()}),
  })
}

export const drizzleProvider = {
  provide: DRIZZLE_DB,
  useFactory: createDrizzle,
}

With CustomLogger you can customize the log output content, these are the SQL queries generated by Drizzle ORM, enabling this log helps us verify the N+1 Query problem

Create file src/pothos-graphql/builder.ts

import SchemaBuilder from '@pothos/core'
import DrizzlePlugin from '@pothos/plugin-drizzle'
import type {ExtractTablesWithRelations} from 'drizzle-orm'
import type {DrizzleDB} from 'src/drizzle-orm'
import * as schema from 'src/drizzle-orm/schema/schema'

export interface GraphQLContext {
  db: DrizzleDB
}

type Schema = typeof schema

type DrizzleTableRelations = ExtractTablesWithRelations<Schema>

export const builder = new SchemaBuilder<{
  Context: GraphQLContext
  DrizzleSchema: Schema
  DrizzleRelations: DrizzleTableRelations
}>({
  plugins: [DrizzlePlugin],
  drizzle: {
    client: ((ctx: GraphQLContext) => ctx.db) as never,
    schema,
  },
})

builder.queryType({})

Create file src/pothos-graphql/schema/product.schema.ts

import {
  resolveCursorConnection,
  type ResolveCursorConnectionArgs,
} from '@pothos/plugin-relay'
import {and, asc, desc, eq, gt, lt} from 'drizzle-orm'
import {products} from 'src/drizzle-orm/schema/schema'
import {builder} from '../builder'

export const ProductVariantType = builder.drizzleObject('productVariants', {
  name: 'ProductVariant',
  fields: t => ({
    id: t.exposeInt('id'),
    sku: t.exposeString('sku'),
    variationName: t.exposeString('variationName'),
    price: t.float({
      resolve: variant => Number(variant.price),
    }),
    stock: t.exposeInt('stock'),
  }),
})

const ProductType = builder.drizzleObject('products', {
  name: 'Product',
  fields: t => ({
    id: t.exposeInt('id'),
    name: t.exposeString('name'),
    description: t.exposeString('description', {nullable: true}),
    variants: t.relation('variants'),
  }),
})

builder.queryFields(t => ({
  products: t.connection({
    type: ProductType,
    resolve: (_root, args, ctx) => {
      return resolveCursorConnection(
        {
          args,
          toCursor: product => String(product.id),
        },
        ({before, after, limit, inverted}: ResolveCursorConnectionArgs) => {
          const filters = [
            before ? lt(products.id, Number(before)) : undefined,
            after ? gt(products.id, Number(after)) : undefined,
          ].filter(Boolean)
          return ctx.db
            .select()
            .from(products)
            .where(filters.length > 0 ? and(...filters) : undefined)
            .orderBy(inverted ? desc(products.id) : asc(products.id))
            .limit(limit)
        }
      )
    },
  }),

  product: t.drizzleField({
    type: ProductType,
    nullable: true,
    args: {
      id: t.arg.int({required: true}),
    },
    resolve: (query, _root, args, ctx) => {
      return ctx.db.query.products.findFirst(
        query({
          where: eq(products.id, args.id),
        })
      )
    },
  }),
}))
  • Query product list uses resolveCursorConnection to implement cursor pagination
  • builder.drizzleObject acts as a type definition bridge between Drizzle Schema and GraphQL Schema.
  • The ProductType section uses variants: t.relation('variants'), which is pulled from productsRelations defined in schema.ts above and it will utilize AST-Based Query Resolution
  • Normally in GraphQL, each field resolver executes completely independently and does not know what data other fields require. However Pothos and Drizzle resolve N+1 by:
    • Reading GraphQL AST (GraphQLResolveInfo): When a request arrives, the plugin inspects the GraphQL AST syntax tree from the root resolver's info argument to know which fields and relationships the client is querying (for example: which products the client requests to select subsequent variants).
    • Matching with Drizzle Schema Relations: Based on relation declarations in the Drizzle schema, the plugin automatically maps selected fields from the GraphQL AST to relationships in the database.
    • Building a single optimized Query: Instead of running db.select().from(products) and looping through sub-queries, the plugin translates the entire request into Drizzle Relational Query syntax (db.query.products.findMany({ with: { variants: true } })).
    • Executing on the Database: Drizzle ORM underneath converts that command into a single SQL statement (typically using LEFT JOIN or aggregated queries with json_build_object / json_agg depending on the dialect) to fetch exact data required by the client in a single round-trip.

Create file src/pothos-graphql/schema/cart.schema.ts

import {eq} from 'drizzle-orm'
import {carts} from 'src/drizzle-orm/schema/schema'
import {builder} from '../builder'

builder.drizzleObject('cartItems', {
  name: 'CartItem',
  fields: t => ({
    id: t.exposeInt('id'),
    quantity: t.exposeInt('quantity'),
    variant: t.relation('variant'),
  }),
})

const CartType = builder.drizzleObject('carts', {
  name: 'Cart',
  fields: t => ({
    id: t.exposeInt('id'),
    userId: t.exposeInt('userId'),

    items: t.relation('items'),

    totalQuantity: t.field({
      type: 'Int',
      select: {
        columns: {},
        with: {
          items: {
            columns: {
              quantity: true,
            },
          },
        },
      },
      resolve: cart =>
        cart.items.reduce((sum, item) => sum + (item.quantity ?? 0), 0),
    }),

    totalAmount: t.field({
      type: 'Float',
      select: {
        columns: {},
        with: {
          items: {
            columns: {
              quantity: true,
            },
            with: {
              variant: {
                columns: {
                  price: true,
                },
              },
            },
          },
        },
      },
      resolve: cart =>
        cart.items.reduce(
          (sum, item) =>
            sum + (item.quantity ?? 0) * Number(item.variant.price),
          0
        ),
    }),
  }),
})

builder.queryFields(t => ({
  cart: t.drizzleField({
    type: CartType,
    nullable: true,
    args: {
      id: t.arg.int({required: true}),
    },
    resolve: (query, _root, args, ctx) =>
      ctx.db.query.carts.findFirst(
        query({
          where: eq(carts.id, args.id),
        })
      ),
  }),
}))
  • cartItems also uses variant: t.relation('variant') to reference the product variant
    • items: t.relation('items') references cart items
    • It will leverage AST Analysis to merge the whole hierarchical GraphQL query into a single Drizzle query, solving the N+1 issue without writing a DataLoader
  • With totalQuantity and totalAmount as computed fields added to the cart detail response data, this is a Query Optimization (SQL Select Binding/Relation Select) mechanism helping to resolve N+1 query problems right from the database query level.
    • Instead of calling DB multiple times like DataLoader, Pothos relies on the GraphQL Request from the client to automatically merge query configuration into query parameters inside the Drizzle Field resolve function.
    • The task of select is to inform Pothos that if the client requests this field (like totalAmount), Drizzle needs to fetch (SELECT/JOIN) additional related data from the database so the resolve function has enough data for computation
    • The syntax is identical to using findFirst / findMany in Drizzle relational queries.
select: {
  columns: {},
  with: {
    items: {
      columns: {
        quantity: true,
      },
    },
  },
}
  • Meaning: When the GraphQL client requests the totalQuantity field, Pothos will use Drizzle to retrieve cart info while simultaneously performing a JOIN to cartItems and only retrieving the quantity field
  • The resolve function: Receives the cart.items result containing an array of objects { quantity: number } and runs a reduce function to sum them up.
select: {
  columns: {},
  with: {
    items: {
      columns: {
        quantity: true,
      },
      with: {
        variant: {
          columns: {
            price: true,
          },
        },
      },
    },
  },
}
  • Meaning: To calculate the total amount, besides the quantity of each cartItem, you need the additional price field residing in the product_variants table. This configuration instructs Drizzle to automatically perform a Nested Include carts -> cartItems -> variant
  • The resolve function: Receives nested data cart.items[i].variant.price to multiply with quantity

Create file src/pothos-graphql/schema/index.ts

import {builder} from '../builder'

import './cart.schema'
import './product.schema'

export const executableSchema = builder.toSchema()

Afterward you still need the graphql.controller.ts file using Yoga GraphQL and app.module.ts to import controllers as I mentioned in the previous article, you can check back to use it

Result when fetching product detail is as followsalt text

Check logs to view the generated SQL query

[SQL Query] Query: select "products"."id", "products"."created_at", "products"."updated_at", "products"."name", "products"."description", "products_variants"."data" as "variants" from "products" "products" left join lateral (select coalesce(json_agg(json_build_array("products_variants"."id", "products_variants"."created_at", "products_variants"."updated_at", "products_variants"."product_id", "products_variants"."sku", "products_variants"."variation_name", "products_variants"."price", "products_variants"."stock")), '[]'::json) as "data" from "product_variants" "products_variants" where "products_variants"."product_id" = "products"."id") "products_variants" on true where "products"."id" = $1 limit $2 -- params: [10, 1]

Get product listalt text

You can see that only 2 queries were generated instead of calling a separate query for each variant

[SQL Query] Query: select "id", "created_at", "updated_at", "name", "description" from "products" order by "products"."id" asc limit $1 -- params: [6]
[SQL Query] Query: select "products"."description", "products"."id", "products"."name", "products_variants"."data" as "variants" from "products" "products" left join lateral (select coalesce(json_agg(json_build_array("products_variants"."id", "products_variants"."created_at", "products_variants"."updated_at", "products_variants"."product_id", "products_variants"."sku", "products_variants"."variation_name", "products_variants"."price", "products_variants"."stock")), '[]'::json) as "data" from "product_variants" "products_variants" where "products_variants"."product_id" = "products"."id") "products_variants" on true where "products"."id" in ($1, $2, $3, $4, $5) -- params: [1, 2, 3, 4, 5]

Get cart detailalt text

You can see that only 1 SQL statement was created joining everything required

[SQL Query] Query: select "carts"."id", "carts"."created_at", "carts"."updated_at", "carts"."user_id", "carts_items"."data" as "items" from "carts" "carts" left join lateral (select coalesce(json_agg(json_build_array("carts_items"."id", "carts_items"."created_at", "carts_items"."updated_at", "carts_items"."cart_id", "carts_items"."variant_id", "carts_items"."quantity", "carts_items_variant"."data")), '[]'::json) as "data" from "cart_items" "carts_items" left join lateral (select json_build_array("carts_items_variant"."id", "carts_items_variant"."created_at", "carts_items_variant"."updated_at", "carts_items_variant"."product_id", "carts_items_variant"."sku", "carts_items_variant"."variation_name", "carts_items_variant"."price", "carts_items_variant"."stock") as "data" from (select * from "product_variants" "carts_items_variant" where "carts_items_variant"."id" = "carts_items"."variant_id" limit $1) "carts_items_variant") "carts_items_variant" on true where "carts_items"."cart_id" = "carts"."id") "carts_items" on true where "carts"."id" = $2 limit $3 -- params: [1, 2011, 1]

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Understanding React Server Component

Kubernetes Deployment for Zero Downtime

Sitemap

Deploying a NodeJS Server on Google Kubernetes Engine

React Practice Series

Docker Practice Series

Setting up Kubernetes Dashboard with Kind

Helm for beginer - Deploy nginx to Google Kubernetes Engine

DevOps Practice Series