Day 2 — Schema, documents, indexes
The one idea. In Convex you don't write a query and hope the planner finds an index. You declare the index, and the query names it. Access patterns are a design-time decision, not a runtime optimisation.
#1. Documents, but typed
Convex stores documents. Each gets _id and _creationTime for free. Unlike a
schemaless store, you declare the shape in convex/schema.ts and the types flow all the
way to your React hooks with no codegen step you have to remember to run.
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
messages: defineTable({
body: v.string(),
user: v.id("users"),
}),
users: defineTable({
name: v.string(),
tokenIdentifier: v.string(),
}).index("by_token", ["tokenIdentifier"]),
});v.id("users") is the part to notice. It is not "a string that happens to hold an id" —
it is typed as a reference to that table, and it will fail validation if you hand it an id
from somewhere else. That is a foreign key that TypeScript can see.
Validators you have available:
v.id(table) v.null() v.int64() v.number() v.boolean() v.string() v.bytes()
v.commitTs() v.any() v.array() v.object() v.record(k,v)
v.union(...) v.literal(x) v.optional(x) v.nullable(x)v.number() is Float64. Reach for v.int64() only when you genuinely need BigInt
semantics — and note that if you're modelling money, neither one is the right answer;
store integer minor units in a v.number() and never let a float near a currency.
#2. Reading
const task = await ctx.db.get("tasks", args.taskId);
await ctx.db.query("users").take(5);
await ctx.db.query("counter").unique(); // throws unless exactly one
await ctx.db.query("messages").order("desc").collect();Stop and read this twice.
ctx.db.get("tasks", id)takes the table name first. Nearly every video tutorial — including the excellent ones in this repo — showsctx.db.get(id). When Part 1's code doesn't typecheck for you, this is why. You are not confused; the ecosystem moved.
#3. Writing
const id = await ctx.db.insert("tasks", { text: args.text });
await ctx.db.patch("tasks", id, { tag: "bar" }); // shallow merge
await ctx.db.replace("tasks", id, { text: "new" }); // full overwrite
await ctx.db.delete("tasks", id);patch merges shallowly. It is the one people get wrong: patching a nested object replaces
that whole nested object, it does not deep-merge into it.
#4. Indexes — the day's real content
The story. A library with no catalogue. You want every book by one author. The only method available is to walk the shelves and read every spine. With 50 books that's a minute's work and you'd never bother building a catalogue. With 500,000 you are still walking at closing time. The catalogue isn't an optimisation you add when the library gets slow — it's the thing that decides whether a library is possible.
.filter() walks the shelves. .withIndex() uses the catalogue.
// ❌ scans the table, then throws most of it away
ctx.db.query("messages")
.filter((q) => q.eq(q.field("author"), "Tom"))
.collect();
// ✅ seeks directly, given .index("by_author", ["author"]) in the schema
await ctx.db.query("messages")
.withIndex("by_author", (q) => q.eq("author", "Tom"))
.collect();Both return identical results. In a dev database of 50 seeded rows they take indistinguishable time. That is exactly why this bug reaches production: it is invisible at the scale where you write it and fatal at the scale where you need it.
Same warning for a bare .collect(). ctx.db.query("movies").collect() says "load the
entire table into memory," and it will keep working right up until it doesn't. Bound every
read: an index, .take(n), or .paginate().
#Composite indexes and prefix rules
.index("by_team_and_user", ["team", "user"])Field order matters. This index serves:
.withIndex("by_team_and_user", (q) => q.eq("team", teamId)) // ✅ prefix
.withIndex("by_team_and_user", (q) => q.eq("team", teamId).eq("user", userId)) // ✅ full...but not a query on user alone. And because the composite already serves team-only
queries, a separate by_team index is redundant — delete it. Redundant indexes cost
you write throughput and storage and buy nothing.
#Quick check 1
You have
.index("by_org_and_status_and_created", ["org", "status", "created"]). Which of these can use it? (a) org only (b) status only (c) org + status (d) org + created, skipping statusAnswer
(a) ✅ and (c) ✅ — both are prefixes. (b) ❌ —
statusis not a prefix; the index is ordered byorgfirst. (d) ❌ — you cannot skip a middle field and still seek; you'd get everything for that org and filtercreatedafterwards, which is a scan wearing a disguise.
#Quick check 2
Your
by_authorquery is instant in dev and times out in production. The code did not change. What happened?Answer
Nothing happened to the code — the table got big. You almost certainly used
.filter()or an unbounded.collect(), which are O(table). Dev had 50 rows. This is the single most common Convex production incident, and it is why indexes are a Day 2 topic rather than a Day 7 one.
#Build task
Build a link-saver schema — you will grow this app for the rest of the bootcamp.
- Two tables:
links(url,title,noteoptional,ownerId,archivedboolean) andtags(name,linkId). - Use
v.optional()fornoteandv.id("links")fortags.linkId. - Write
listByOwnertwice: once with.filter(), once with.withIndex(). - Seed ~2,000 rows (a loop in an
internalMutationis fine). Run both. Compare the function timings in the dashboard Logs tab. - Delete the
.filter()version. Keep the number you measured.
Step 4 is the point of the day. Do not skip it — a measured number changes your instincts in a way that reading this page cannot.
#Checkpoint
- Where does an index get declared, and where does it get named?
- Why is
.filter()dangerous specifically because it works fine in development? - Given
by_team_and_user, why is a separateby_teamindex waste? - What's the difference between
patchandreplaceon a document with a nested object? - Write, from memory, the current call to fetch one
tasksdocument by id.
#Cheat strip
schema.ts : defineSchema({ table: defineTable({...}).index("by_x", ["x"]) })
read : ctx.db.get("tasks", id) ← TABLE NAME FIRST
.query(t).withIndex(name, q => q.eq(f, v)).take(n) / .unique() / .first()
write : insert(t, doc) · patch(t, id, partial) · replace(t, id, doc) · delete(t, id)
index : prefix rule — [a,b,c] serves a, ab, abc. Never b alone.
composite exists ⇒ delete the prefix-only index
never : unbounded .collect() · .filter() as a substitute for an index#Tomorrow
Day 3 wires this to React and you finally see the payoff: two browser windows, one mutation, zero lines of WebSocket code.