Data with Convex
Schema design & indexes
Lesson 3 of 7
What you'll learn
- Define a typed table with
defineTableand 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.
_creationTimeis 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.
The index is sorted by [userId, lessonId]. Run it to see which queries the index can serve efficiently and which can't.
Why does Convex require you to declare an index for each access pattern instead of choosing one for you?
With a schema in place, the magic begins: queries that update themselves.
Saved on this device. Sign in to sync your progress everywhere.