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.
With a schema in place, the magic begins: queries that update themselves.
Sign in to save your progress across devices.