Database
Database Queries
Database Queries
Master type-safe database operations with Convex. This guide covers everything from basic CRUD operations to complex queries and performance optimization.
Query Fundamentals
Basic Query Structure
Convex provides a fully typed API with TypeScript support:
// convex/users.ts
import { v } from 'convex/values';
import { query, mutation } from './_generated/server';
// Select all users
export const getAllUsers = query({
handler: async (ctx) => {
return await ctx.db.query('users').collect();
},
});
// Select specific user by ID
export const getUserById = query({
args: { id: v.id('users') },
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});
// Select with conditions using index
export const getActiveAdmins = query({
handler: async (ctx) => {
return await ctx.db
.query('users')
.withIndex('by_role', (q) => q.eq('role', 'admin'))
.filter((q) => q.eq(q.field('isActive'), true))
.collect();
},
});Type Safety
Convex ensures complete type safety across all operations:
// Type-safe - TypeScript knows the exact shape
export const getUser = query({
args: { userId: v.id('users') },
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
// user: Doc<"users"> | null - fully typed!
return user;
},
});
// Compile-time error prevention
export const badQuery = query({
args: { userId: v.id('users') },
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
// TypeScript error: 'nonExistentField' does not exist
// return user?.nonExistentField;
},
});CRUD Operations
Create (Insert)
// convex/users.ts
import { v } from 'convex/values';
import { mutation } from './_generated/server';
// Insert single record
export const createUser = mutation({
args: {
email: v.string(),
name: v.string(),
role: v.optional(v.union(v.literal('admin'), v.literal('user'))),
},
handler: async (ctx, args) => {
const userId = await ctx.db.insert('users', {
email: args.email,
name: args.name,
role: args.role ?? 'user',
createdAt: Date.now(),
updatedAt: Date.now(),
});
return userId; // Returns the new document ID
},
});
// Insert with conflict handling (upsert pattern)
export const upsertUser = mutation({
args: {
email: v.string(),
name: v.string(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query('users')
.withIndex('by_email', (q) => q.eq('email', args.email))
.first();
if (existing) {
// Update existing user
await ctx.db.patch(existing._id, {
name: args.name,
updatedAt: Date.now(),
});
return existing._id;
} else {
// Create new user
return await ctx.db.insert('users', {
email: args.email,
name: args.name,
role: 'user',
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
},
});Read (Select)
// convex/users.ts
import { v } from 'convex/values';
import { query } from './_generated/server';
// Select all
export const getAllUsers = query({
handler: async (ctx) => {
return await ctx.db.query('users').collect();
},
});
// Select with index condition
export const getAdminUsers = query({
handler: async (ctx) => {
return await ctx.db
.query('users')
.withIndex('by_role', (q) => q.eq('role', 'admin'))
.collect();
},
});
// Select with multiple conditions
export const getActiveAdmins = query({
handler: async (ctx) => {
return await ctx.db
.query('users')
.withIndex('by_role', (q) => q.eq('role', 'admin'))
.filter((q) => q.eq(q.field('isActive'), true))
.collect();
},
});
// Select with ordering
export const getRecentUsers = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
return await ctx.db
.query('users')
.order('desc') // Orders by _creationTime
.take(args.limit ?? 10);
},
});
// Select with pagination
export const getPaginatedUsers = query({
args: {
page: v.number(),
pageSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
const pageSize = args.pageSize ?? 20;
const users = await ctx.db.query('users').order('desc').collect();
const start = (args.page - 1) * pageSize;
const end = start + pageSize;
return {
users: users.slice(start, end),
totalPages: Math.ceil(users.length / pageSize),
currentPage: args.page,
};
},
});Update
// convex/users.ts
import { v } from 'convex/values';
import { mutation } from './_generated/server';
// Update single record
export const updateUser = mutation({
args: {
id: v.id('users'),
name: v.optional(v.string()),
email: v.optional(v.string()),
},
handler: async (ctx, args) => {
const { id, ...updates } = args;
await ctx.db.patch(id, {
...updates,
updatedAt: Date.now(),
});
return await ctx.db.get(id);
},
});
// Update multiple records
export const deactivateInactiveUsers = mutation({
args: { inactiveDays: v.number() },
handler: async (ctx, args) => {
const cutoff = Date.now() - args.inactiveDays * 24 * 60 * 60 * 1000;
const inactiveUsers = await ctx.db
.query('users')
.filter((q) => q.and(q.eq(q.field('isActive'), true), q.lt(q.field('lastLoginAt'), cutoff)))
.collect();
for (const user of inactiveUsers) {
await ctx.db.patch(user._id, {
isActive: false,
updatedAt: Date.now(),
});
}
return inactiveUsers.length;
},
});
// Replace entire document
export const replaceUser = mutation({
args: {
id: v.id('users'),
data: v.object({
email: v.string(),
name: v.string(),
role: v.union(v.literal('admin'), v.literal('user')),
}),
},
handler: async (ctx, args) => {
await ctx.db.replace(args.id, {
...args.data,
createdAt: Date.now(),
updatedAt: Date.now(),
});
},
});Delete
// convex/users.ts
import { v } from 'convex/values';
import { mutation } from './_generated/server';
// Delete single record
export const deleteUser = mutation({
args: { id: v.id('users') },
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
},
});
// Delete with conditions
export const deleteOldUsers = mutation({
args: { olderThanDays: v.number() },
handler: async (ctx, args) => {
const cutoff = Date.now() - args.olderThanDays * 24 * 60 * 60 * 1000;
const oldUsers = await ctx.db
.query('users')
.filter((q) => q.and(q.eq(q.field('isActive'), false), q.lt(q.field('createdAt'), cutoff)))
.collect();
const deletedIds = [];
for (const user of oldUsers) {
await ctx.db.delete(user._id);
deletedIds.push(user._id);
}
return { deletedCount: deletedIds.length, deletedIds };
},
});Advanced Queries
Related Data (Joins)
// convex/users.ts
// Fetch user with related data
export const getUserWithProfile = query({
args: { userId: v.id('users') },
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user) return null;
const profile = await ctx.db
.query('userProfiles')
.withIndex('by_userId', (q) => q.eq('userId', args.userId))
.first();
const posts = await ctx.db
.query('posts')
.withIndex('by_userId', (q) => q.eq('userId', args.userId))
.filter((q) => q.eq(q.field('status'), 'published'))
.order('desc')
.take(5);
return {
...user,
profile,
recentPosts: posts,
};
},
});
// Multiple related records
export const getUsersWithSubscriptions = query({
handler: async (ctx) => {
const users = await ctx.db.query('users').collect();
const usersWithSubs = await Promise.all(
users.map(async (user) => {
const subscription = await ctx.db
.query('subscriptions')
.withIndex('by_userId', (q) => q.eq('userId', user._id))
.first();
return {
...user,
subscription,
};
})
);
return usersWithSubs;
},
});Aggregations
// convex/analytics.ts
import { query } from './_generated/server';
// Count records
export const getUserCount = query({
handler: async (ctx) => {
const users = await ctx.db.query('users').collect();
return users.length;
},
});
// Count with conditions
export const getActiveUserCount = query({
handler: async (ctx) => {
const activeUsers = await ctx.db
.query('users')
.filter((q) => q.eq(q.field('isActive'), true))
.collect();
return activeUsers.length;
},
});
// Group by with aggregations
export const getPostsByStatus = query({
handler: async (ctx) => {
const posts = await ctx.db.query('posts').collect();
const statusCounts = posts.reduce(
(acc, post) => {
acc[post.status] = (acc[post.status] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
const viewStats = {
totalViews: posts.reduce((sum, p) => sum + (p.viewCount || 0), 0),
avgViews:
posts.length > 0 ? posts.reduce((sum, p) => sum + (p.viewCount || 0), 0) / posts.length : 0,
maxViews: Math.max(...posts.map((p) => p.viewCount || 0)),
};
return { statusCounts, viewStats };
},
});
// Complex aggregation
export const getProlificAuthors = query({
args: { minPosts: v.number() },
handler: async (ctx, args) => {
const posts = await ctx.db.query('posts').collect();
// Group posts by author
const postsByAuthor = posts.reduce(
(acc, post) => {
const authorId = post.userId.toString();
if (!acc[authorId]) {
acc[authorId] = [];
}
acc[authorId].push(post);
return acc;
},
{} as Record<string, typeof posts>
);
// Filter authors with minimum posts
const prolificAuthorIds = Object.entries(postsByAuthor)
.filter(([_, authorPosts]) => authorPosts.length >= args.minPosts)
.map(([authorId]) => authorId);
// Fetch author details
const authors = await Promise.all(
prolificAuthorIds.map(async (authorId) => {
const user = await ctx.db.get(authorId as any);
return {
user,
postCount: postsByAuthor[authorId].length,
};
})
);
return authors.filter((a) => a.user !== null);
},
});Complex Filtering
Multiple Conditions
// convex/search.ts
import { v } from 'convex/values';
import { query } from './_generated/server';
// Complex AND/OR conditions
export const searchUsers = query({
args: {
role: v.optional(v.union(v.literal('admin'), v.literal('user'))),
isActive: v.optional(v.boolean()),
searchTerm: v.optional(v.string()),
},
handler: async (ctx, args) => {
let users = await ctx.db.query('users').collect();
// Apply filters
if (args.role) {
users = users.filter((u) => u.role === args.role);
}
if (args.isActive !== undefined) {
users = users.filter((u) => u.isActive === args.isActive);
}
if (args.searchTerm) {
const term = args.searchTerm.toLowerCase();
users = users.filter(
(u) => u.name.toLowerCase().includes(term) || u.email.toLowerCase().includes(term)
);
}
return users;
},
});
// Date range queries
export const getRecentUsers = query({
args: {
startDate: v.number(),
endDate: v.number(),
},
handler: async (ctx, args) => {
return await ctx.db
.query('users')
.filter((q) =>
q.and(
q.gte(q.field('createdAt'), args.startDate),
q.lte(q.field('createdAt'), args.endDate)
)
)
.collect();
},
});
// Null checks
export const getUsersWithoutProfiles = query({
handler: async (ctx) => {
const users = await ctx.db.query('users').collect();
const usersWithoutProfiles = await Promise.all(
users.map(async (user) => {
const profile = await ctx.db
.query('userProfiles')
.withIndex('by_userId', (q) => q.eq('userId', user._id))
.first();
return profile === null ? user : null;
})
);
return usersWithoutProfiles.filter((u) => u !== null);
},
});Text Search
// convex/search.ts
// Case-insensitive search
export const searchPosts = query({
args: { searchTerm: v.string() },
handler: async (ctx, args) => {
const posts = await ctx.db.query('posts').collect();
const term = args.searchTerm.toLowerCase();
return posts.filter(
(post) =>
post.title.toLowerCase().includes(term) || post.content?.toLowerCase().includes(term)
);
},
});
// Multiple word search
export const searchByTags = query({
args: { tags: v.array(v.string()) },
handler: async (ctx, args) => {
const posts = await ctx.db.query('posts').collect();
return posts.filter((post) => {
const content = (post.content || '').toLowerCase();
return args.tags.every((tag) => content.includes(tag.toLowerCase()));
});
},
});Query Builders and Helpers
Reusable Query Functions
// convex/helpers/userQueries.ts
import { v } from 'convex/values';
import { query } from '../_generated/server';
// Find user by email
export const findByEmail = query({
args: { email: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query('users')
.withIndex('by_email', (q) => q.eq('email', args.email))
.first();
},
});
// Find active users
export const findActive = query({
handler: async (ctx) => {
return await ctx.db
.query('users')
.filter((q) => q.eq(q.field('isActive'), true))
.collect();
},
});
// Find users by role
export const findByRole = query({
args: { role: v.union(v.literal('user'), v.literal('admin')) },
handler: async (ctx, args) => {
return await ctx.db
.query('users')
.withIndex('by_role', (q) => q.eq('role', args.role))
.collect();
},
});
// Paginated users
export const findPaginated = query({
args: {
page: v.number(),
pageSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
const pageSize = args.pageSize ?? 20;
const users = await ctx.db.query('users').order('desc').collect();
const start = (args.page - 1) * pageSize;
return users.slice(start, start + pageSize);
},
});
// Search users
export const search = query({
args: { term: v.string() },
handler: async (ctx, args) => {
const users = await ctx.db.query('users').collect();
const searchTerm = args.term.toLowerCase();
return users.filter(
(user) =>
user.name.toLowerCase().includes(searchTerm) ||
user.email.toLowerCase().includes(searchTerm)
);
},
});Dynamic Query Building
// convex/users.ts
export const findUsersWithFilters = query({
args: {
role: v.optional(v.union(v.literal('user'), v.literal('admin'))),
isActive: v.optional(v.boolean()),
search: v.optional(v.string()),
createdAfter: v.optional(v.number()),
createdBefore: v.optional(v.number()),
},
handler: async (ctx, args) => {
let users = await ctx.db.query('users').order('desc').collect();
// Apply role filter
if (args.role) {
users = users.filter((u) => u.role === args.role);
}
// Apply active filter
if (args.isActive !== undefined) {
users = users.filter((u) => u.isActive === args.isActive);
}
// Apply search filter
if (args.search) {
const term = args.search.toLowerCase();
users = users.filter(
(u) => u.name.toLowerCase().includes(term) || u.email.toLowerCase().includes(term)
);
}
// Apply date range filters
if (args.createdAfter) {
users = users.filter((u) => u.createdAt >= args.createdAfter!);
}
if (args.createdBefore) {
users = users.filter((u) => u.createdAt <= args.createdBefore!);
}
return users;
},
});Performance Optimization
Using Indexes Effectively
// GOOD: Uses index for fast lookup
export const getUserByEmail = query({
args: { email: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query('users')
.withIndex('by_email', (q) => q.eq('email', args.email))
.first();
},
});
// GOOD: Prepared-style query pattern
export const getUserPosts = query({
args: {
userId: v.id('users'),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
return await ctx.db
.query('posts')
.withIndex('by_userId', (q) => q.eq('userId', args.userId))
.order('desc')
.take(args.limit ?? 10);
},
});Efficient Data Fetching
// GOOD: Parallel data fetching
export const getDashboardData = query({
args: { userId: v.id('users') },
handler: async (ctx, args) => {
const [user, posts, subscriptions] = await Promise.all([
ctx.db.get(args.userId),
ctx.db
.query('posts')
.withIndex('by_userId', (q) => q.eq('userId', args.userId))
.take(5),
ctx.db
.query('subscriptions')
.withIndex('by_userId', (q) => q.eq('userId', args.userId))
.collect(),
]);
return { user, recentPosts: posts, subscriptions };
},
});
// Batch operations
export const batchInsertUsers = mutation({
args: {
users: v.array(
v.object({
email: v.string(),
name: v.string(),
})
),
},
handler: async (ctx, args) => {
const insertedIds = [];
for (const userData of args.users) {
const id = await ctx.db.insert('users', {
...userData,
role: 'user',
isActive: true,
createdAt: Date.now(),
updatedAt: Date.now(),
});
insertedIds.push(id);
}
return insertedIds;
},
});Query Security
Input Validation
import { v } from 'convex/values';
import { query } from './_generated/server';
// Convex validates input automatically
export const searchUsers = query({
args: {
term: v.string(), // Validated as string
role: v.optional(v.union(v.literal('user'), v.literal('admin'))), // Enum validation
limit: v.optional(v.number()), // Validated as number
},
handler: async (ctx, args) => {
// Additional validation if needed
const limit = Math.min(args.limit ?? 20, 100); // Cap at 100
let users = await ctx.db.query('users').take(limit);
if (args.role) {
users = users.filter((u) => u.role === args.role);
}
if (args.term) {
const term = args.term.toLowerCase();
users = users.filter(
(u) => u.name.toLowerCase().includes(term) || u.email.toLowerCase().includes(term)
);
}
return users;
},
});Permission Checks
// convex/users.ts
import { v } from 'convex/values';
import { query } from './_generated/server';
import { getCurrentUser } from './auth';
// Row-level security helper
export const getUserData = query({
args: { targetUserId: v.id('users') },
handler: async (ctx, args) => {
const currentUser = await getCurrentUser(ctx);
if (!currentUser) {
throw new Error('Authentication required');
}
// Admin can access any user, users can only access their own data
if (currentUser.role !== 'admin' && currentUser._id !== args.targetUserId) {
throw new Error('Insufficient permissions');
}
return await ctx.db.get(args.targetUserId);
},
});Best Practices
1. Query Organization
// GOOD: Organized query functions by domain
// convex/users.ts - User queries
// convex/posts.ts - Post queries
// convex/subscriptions.ts - Subscription queries
// AVOID: Inline queries in components without proper abstraction2. Error Handling
export const safeGetUser = query({
args: { userId: v.id('users') },
handler: async (ctx, args) => {
try {
const user = await ctx.db.get(args.userId);
if (!user) {
return { success: false, error: 'User not found' };
}
return { success: true, data: user };
} catch (error) {
console.error('Database query failed:', error);
return { success: false, error: 'Database error' };
}
},
});3. Type Safety
// GOOD: Explicit return types
export const getUsersWithPostCounts = query({
handler: async (ctx) => {
const users = await ctx.db.query('users').collect();
const usersWithCounts = await Promise.all(
users.map(async (user) => {
const posts = await ctx.db
.query('posts')
.withIndex('by_userId', (q) => q.eq('userId', user._id))
.collect();
return {
id: user._id,
name: user.name,
email: user.email,
postCount: posts.length,
};
})
);
return usersWithCounts;
},
});Next Steps
Master database queries with these next guides:
- Migrations - Manage schema changes safely
- Performance - Optimize query performance
- Security - Secure database operations
- Advanced - Advanced query techniques
With these query patterns, you can build efficient, type-safe database operations that scale with your Solo Kit application.