Database Index for Tasks Table

Right now, our tasks.list query uses .filter() to find tasks for a project:

export const list = query({
  args: {
    projectId: v.id("projects"),
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("tasks")
      .filter((q) => q.eq(q.field("projectId"), args.projectId))
      .collect();
  },
});

This is better than client-side filtering, but there is a performance issue here. The .filter() method performs a full table scan: Convex reads every document in the tasks table and checks whether its projectId matches. For a small table, this is fine. For a table with hundreds of thousands of documents, it gets slow.

Database Indexes

An index is like a pre-sorted lookup table that the database maintains alongside your data. Instead of scanning every document and checking if it matches, the database jumps directly to the matching documents.

Think of it like a book’s index at the back. If you want to find every mention of “React” in a 500-page book, you have two options:

  • Without an index: read every page and check for the word “React” (full table scan)
  • With an index: flip to the back, look up “React”, and go directly to pages 12, 47, and 203

Databases work the same way. When you create an index on projectId, the database organizes tasks by project ID behind the scenes. Finding all tasks for a project becomes a direct lookup instead of a full scan.

Define the Index

Update convex/schema.ts to add an index on the tasks table:

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  projects: defineTable({
    name: v.string(),
    description: v.optional(v.string()),
  }),
  tasks: defineTable({
    projectId: v.id("projects"),
    title: v.string(),
    description: v.string(),
    status: v.union(
      v.literal("todo"),
      v.literal("in-progress"),
      v.literal("done"),
    ),
  }).index("by_project", ["projectId"]),  // 👀
});

The new part is .index("by_project", ["projectId"]) chained after defineTable(...). This tells Convex to create an index named "by_project" sorted by the projectId field.

An index definition has two parts:

  • Name — a string that identifies the index (e.g., "by_project"). You will reference this name when querying.
  • Fields — an array of field names the index covers. Convex sorts documents by these fields in order.

You can create multi-field indexes too. For example, .index("by_project_status", ["projectId", "status"]) would sort first by projectId, then by status within each project. This is useful when your queries filter on multiple fields.

Use the Index in the Query

Now update the tasks.list query to use the index instead of .filter():

export const list = query({
  args: {
    projectId: v.id("projects"),
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("tasks")
      .withIndex("by_project", (q) => q.eq("projectId", args.projectId))
      .collect();
  },
});

The change is that .filter(...) becomes .withIndex("by_project", ...). The callback syntax is slightly different. With .withIndex(), you pass the field name as a string to q.eq() instead of using q.field().

With this change, Convex uses the index to jump directly to the tasks for the given project.

If npx convex dev is running, the schema change deploys automatically. Convex backfills the index, which means it scans the existing documents and builds the index from them. For small tables this is instant. For large tables it may take a moment.

You do not need to change anything on the frontend. The useQuery(api.tasks.list, { projectId }) call in KanbanBoard keeps working as it is. We changed how the query works inside, from a filter to an indexed lookup, but the interface stayed the same.

Checkpoint: Commit your progress.

git add .
git commit -m "planner-08: Fetch tasks by project using a database index"
git push