BuildBot

Data with Convex

Schema design & indexes

Lesson 3 of 7

What you'll learn

  • Define a typed table with defineTable and validators
  • Understand why Convex requires explicit indexes
  • Name indexes after the fields they cover

Convex is a reactive database where your schema is TypeScript. You declare tables with validators, and Convex enforces them at write time:

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

export default defineSchema({
  progress: defineTable({
    clerkUserId: v.string(),
    lessonId: v.string(),
    courseSlug: v.string(),
    completedAt: v.number(),
  })
    .index("by_user", ["clerkUserId"])
    .index("by_user_and_lesson", ["clerkUserId", "lessonId"]),
});

Why indexes are mandatory

Most databases have a query planner that guesses how to run your query. Convex doesn't — you tell it exactly which index to use with .withIndex(). This trades a little upfront thought for predictable, fast queries that never silently degrade as your data grows.

A few rules from the Convex docs worth memorizing:

  • Up to 16 fields per index, 32 indexes per table.
  • _creationTime is appended to every index automatically.
  • Avoid .collect() on large/unbounded sets — use an index or pagination.

Naming convention

Name an index for the fields it contains: by_user, by_user_and_lesson, by_course. When you read the query later, the index name tells you the access pattern at a glance.

Picking the right index

An index on ["clerkUserId", "lessonId"] can answer "all progress for this user" and "this user's row for this exact lesson" — because index fields are matched left-to-right. The exercise models that prefix-matching.

How a compound index matches

The index is sorted by [userId, lessonId]. Run it to see which queries the index can serve efficiently and which can't.

Loading editor…

With a schema in place, the magic begins: queries that update themselves.

Sign in to save your progress across devices.