Queries & Mutations
Learn how to read and write data with Convex queries and mutations
Queries & Mutations
Queries and mutations are the primary way to interact with your Convex database. This template enhances them with Zod validation and automatic auth context injection.
Query & Mutation Variants
This template defines three variants for each operation type:
| Variant | Auth Method | Use Case |
|---|---|---|
zQuery / zMutation | JWT (user session) | Frontend client calls |
zInternalQuery / zInternalMutation | Identity passed as args | Server-to-server (internal functions, triggers) |
zExternalQuery / zExternalMutation | externalToken in args | Agent-side access (Cloudflare Workers, external services) |
Queries
Queries read data from the database. They are:
- Cached - Results are cached until underlying data changes
- Reactive - Frontend subscriptions automatically update
- Read-only - Cannot modify database state
Basic Query
import { zQuery } from "../functions";
import { z } from "zod";
export const list = zQuery({
args: z.object({}),
handler: async (ctx) => {
return await ctx.table("todos").collect();
},
});Query with Arguments
export const getById = zQuery({
args: z.object({
id: z.string(),
}),
handler: async (ctx, args) => {
return await ctx.table("todos").get(args.id as Id<"todos">);
},
});Query with Filters
export const listByStatus = zQuery({
args: z.object({
status: z.enum(["todo", "in_progress", "done"]),
}),
handler: async (ctx, args) => {
return await ctx.table("todos")
.filter((q) => q.eq(q.field("status"), args.status))
.collect();
},
});Using Indexes
Always use indexes for filtered queries to ensure performance:
export const listByOrganization = zQuery({
args: z.object({}),
handler: async (ctx) => {
// Uses the "organizationId" index
return await ctx.table("todos", "organizationId", (q) =>
q.eq("organizationId", ctx.identity.activeOrganizationId)
).collect();
},
});Using Search Indexes
For full-text search, use the search index defined on the table:
// chats/index.ts
export const search = zQuery({
args: z.object({
query: z.string(),
}),
handler: async (ctx, args) => {
return await ctx.db
.query("chats")
.withSearchIndex("title", (q) =>
q.search("title", args.query)
.eq("organizationId", ctx.identity.activeOrganizationId)
.eq("memberId", ctx.identity.memberId)
)
.collect();
},
});Mutations
Mutations modify data. They are:
- Transactional - All changes succeed or all fail
- Trigger Re-evaluation - Cause dependent queries to update
- Write-capable - Can insert, update, and delete records
Basic Mutation
import { zMutation } from "../functions";
import { z } from "zod";
export const create = zMutation({
args: z.object({
title: z.string().min(1),
priority: z.enum(["low", "medium", "high"]).default("medium"),
}),
handler: async (ctx, args) => {
return await ctx.table("todos").insert({
title: args.title,
priority: args.priority,
status: "todo",
organizationId: ctx.identity.activeOrganizationId,
memberId: ctx.identity.memberId,
updatedAt: Date.now(),
});
},
});Chat Create Mutation
export const create = zMutation({
args: z.object({
title: z.string().min(1),
}),
handler: async (ctx, args) => {
return await ctx.table("chats").insert({
title: args.title,
organizationId: ctx.identity.activeOrganizationId,
memberId: ctx.identity.memberId,
isPinned: false,
updatedAt: Date.now(),
});
},
});Update Mutation
export const update = zMutation({
args: z.object({
id: z.string(),
title: z.string().optional(),
status: z.enum(["todo", "in_progress", "done"]).optional(),
priority: z.enum(["low", "medium", "high"]).optional(),
}),
handler: async (ctx, args) => {
const { id, ...updates } = args;
const todo = await ctx.table("todos").get(id as Id<"todos">);
if (!todo) throw new Error("Todo not found");
// Verify ownership
if (todo.organizationId !== ctx.identity.activeOrganizationId) {
throw new Error("Unauthorized");
}
return await ctx.table("todos").patch(id as Id<"todos">, {
...updates,
updatedAt: Date.now(),
});
},
});Delete Mutation
export const remove = zMutation({
args: z.object({
id: z.string(),
}),
handler: async (ctx, args) => {
const todo = await ctx.table("todos").get(args.id as Id<"todos">);
if (!todo) throw new Error("Todo not found");
if (todo.organizationId !== ctx.identity.activeOrganizationId) {
throw new Error("Unauthorized");
}
await ctx.table("todos").delete(args.id as Id<"todos">);
},
});External Queries & Mutations
For agent-side access (e.g., from Cloudflare Workers), use zExternalQuery and zExternalMutation. These validate an externalToken from args instead of a JWT session:
import { zExternalQuery, zExternalMutation } from "../functions";
// External query: validates externalToken from args
export const getExt = zExternalQuery({
args: z.object({
chatId: z.string(),
}),
handler: async (ctx, args) => {
return await ctx.table("chats").get(args.chatId as Id<"chats">);
},
});
// External mutation: same token validation, write access
export const updateExt = zExternalMutation({
args: z.object({
chatId: z.string(),
title: z.string().optional(),
}),
handler: async (ctx, args) => {
return await ctx.table("chats").patch(args.chatId as Id<"chats">, {
...(args.title && { title: args.title }),
updatedAt: Date.now(),
});
},
});The external wrappers automatically inject externalToken plus the full identity fields (userId, activeOrganizationId, memberId, organizationRole, activeTeamId) into args. The caller must provide all of these. Inside the handler, ctx.identity works the same as zQuery/zMutation.
When to Use External vs Standard
| Scenario | Use |
|---|---|
| Frontend React component | zQuery / zMutation |
| Convex internal function calling another | zInternalQuery / zInternalMutation |
| Cloudflare Worker / AI agent calling Convex | zExternalQuery / zExternalMutation |
Context Object
The ctx object in handlers provides:
| Property | Description |
|---|---|
ctx.table(name) | Access a table with Ents API |
ctx.db | Raw Convex database access |
ctx.identity | Current user's auth context (parsed from JWT or external args) |
ctx.auth | Auth utilities |
Identity Context
When using zQuery/zMutation, the identity is automatically parsed from the JWT. When using zExternalQuery/zExternalMutation, it is parsed from the args after token validation. Either way, the shape is the same:
ctx.identity = {
userId: "user_123", // Better Auth user ID
activeOrganizationId: "org_456", // Current organization
memberId: "member_789", // Member ID within the org
organizationRole: "member", // User's role in org
activeTeamId: "team_012", // Current team (optional)
};Best Practices
1. Always Validate Ownership
// Check org membership before operations
if (todo.organizationId !== ctx.identity.activeOrganizationId) {
throw new Error("Unauthorized");
}2. Use Indexes for Filters
// Good: Uses index
ctx.table("todos", "organizationId", (q) =>
q.eq("organizationId", orgId)
);
// Avoid: Full table scan
ctx.table("todos").filter((q) =>
q.eq(q.field("organizationId"), orgId)
);3. Return Minimal Data
// Return only what the frontend needs
return {
id: todo._id,
title: todo.title,
status: todo.status,
};4. Handle Errors Gracefully
const todo = await ctx.table("todos").get(args.id);
if (!todo) {
throw new Error("Todo not found");
}5. Delegate to Pure Functions
Keep handlers thin by delegating to pure business logic functions:
// chats/index.ts
import * as functions from "./functions";
import * as types from "./types";
export const list = zQuery({
args: types.ListArgs,
handler: async (ctx, args) => {
return await functions.ListChats(ctx, args);
},
});
export const getExt = zExternalQuery({
args: types.GetChatArgs,
handler: async (ctx, args) => {
return await functions.GetChat(ctx, args);
},
});This keeps the index file as a thin routing layer while business logic lives in functions.ts.
Next, learn about Schema Design.