Skip to main content
Convex provides a fully transactional database with reactive queries. All reads and writes within a single mutation execute atomically.

Database reader

The GenericDatabaseReader interface provides read-only access to the database in queries. Access it via ctx.db in query and mutation functions.

Get a document by ID

Fetch a single document from the database by its ID:
import { query } from "./_generated/server";
import { v } from "convex/values";

export const getUser = query({
  args: { userId: v.id("users") },
  handler: async (ctx, args) => {
    const user = await ctx.db.get(args.userId);
    return user; // Returns the document or null if not found
  },
});
You can also specify the table name explicitly:
const user = await ctx.db.get("users", userId);
id
GenericId<TableName>
The ID of the document to fetch from the database.
table
string
The name of the table to fetch the document from (optional first parameter).
Returns: The document at the given ID, or null if it no longer exists.

Query documents

Begin a query for a given table. Queries don’t execute immediately - they’re lazily evaluated when you consume the results.
const messages = await ctx.db
  .query("messages")
  .withIndex("by_channel", (q) => q.eq("channelId", channelId))
  .order("desc")
  .take(50);
tableName
string
The name of the table to query.
Returns: A QueryInitializer object to start building a query.

System tables

Access system tables like _storage (file metadata) and _scheduled_functions (scheduled function state):
// Get file metadata from the _storage system table:
const metadata = await ctx.db.system.get(storageId);
// metadata has: _id, _creationTime, contentType, sha256, size

Normalize ID

Returns the string ID format for an ID in a given table, or null if the ID is from a different table or is not valid:
const normalizedId = ctx.db.normalizeId("users", idString);
tableName
string
The name of the table.
id
string
The ID string to normalize.
Returns: The normalized GenericId<TableName> or null.

Database writer

The GenericDatabaseWriter interface extends GenericDatabaseReader with write operations. Available as ctx.db in mutations. All reads and writes within a single mutation are executed atomically - you never have to worry about partial writes leaving your data in an inconsistent state.

Insert

Insert a new document into a table:
import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const createTask = mutation({
  args: { text: v.string() },
  handler: async (ctx, args) => {
    const taskId = await ctx.db.insert("tasks", {
      text: args.text,
      completed: false,
    });
    return taskId;
  },
});
table
string
The name of the table to insert into.
value
object
The document to insert. System fields (_id, _creationTime) are added automatically and should not be included.
Returns: The GenericId of the newly created document.

Patch

Patch an existing document, shallow merging it with the given partial document:
// Update only the "completed" field, leaving other fields unchanged:
await ctx.db.patch(taskId, { completed: true });

// Remove an optional field by setting it to undefined:
await ctx.db.patch(taskId, { assignee: undefined });
New fields are added. Existing fields are overwritten. Fields set to undefined are removed. Fields not specified in the patch are left unchanged.
id
GenericId<TableName>
The ID of the document to patch.
value
Partial<Document>
The partial document to merge into the existing document.
Tip: Use patch for partial updates. Use replace when you want to overwrite the entire document. Throws: An error if the document does not exist.

Replace

Replace the entire value of an existing document, overwriting its old value completely:
// Replace the entire document:
await ctx.db.replace(userId, {
  name: "New Name",
  email: "[email protected]",
});
Unlike patch, which does a shallow merge, replace overwrites the entire document. Any fields not included in the new value will be removed (except system fields _id and _creationTime).
id
GenericId<TableName>
The ID of the document to replace.
value
Document
The new document. System fields can be omitted.
Throws: An error if the document does not exist.

Delete

Delete an existing document:
await ctx.db.delete(taskId);
To delete multiple documents, collect them first, then delete each one:
const oldTasks = await ctx.db
  .query("tasks")
  .withIndex("by_completed", (q) => q.eq("completed", true))
  .collect();

for (const task of oldTasks) {
  await ctx.db.delete(task._id);
}
id
GenericId<TableName>
The ID of the document to remove.
Note: Convex queries do not support .delete() directly on query results. Collect documents first, then delete each one individually.

Table scoped API

Scope the database to a specific table for cleaner syntax:
const users = ctx.db.table("users");

// All operations are now scoped to the users table:
const user = await users.get(userId);
const allUsers = await users.query().collect();
await users.insert({ name: "Alice", email: "[email protected]" });

Best practices

  • Use .withIndex() instead of .filter() for efficient queries. Define indexes in your schema for fields you query frequently.
  • Avoid .collect() on unbounded queries - it loads all matching documents into memory. Prefer .first(), .unique(), .take(n), or pagination.
  • All mutations are atomic - reads and writes within a single mutation see a consistent snapshot and commit together.
  • System fields like _id and _creationTime are automatically managed - don’t include them when inserting documents.

Build docs developers (and LLMs) love