Skip to main content
Schemas in Convex define the structure of your database tables and provide runtime validation. When you define a schema, you get:
  • Runtime validation - Convex validates all data matches your schema
  • TypeScript types - Automatically generated types for your entire data model
  • Index definitions - Declare indexes for efficient queries
  • Documentation - Self-documenting data structure

Defining a schema

Schemas are defined in a schema.ts file in your convex/ directory using defineSchema, defineTable, and validators from v:
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  users: defineTable({
    name: v.string(),
    email: v.string(),
    age: v.optional(v.number()),
  }).index("by_email", ["email"]),
  
  messages: defineTable({
    body: v.string(),
    userId: v.id("users"),
    channelId: v.id("channels"),
  })
    .index("by_channel", ["channelId"])
    .index("by_user", ["userId"]),
  
  channels: defineTable({
    name: v.string(),
    isPrivate: v.boolean(),
  }),
});
The schema must be the default export from convex/schema.ts. Convex reads this file during deployment to validate your data and generate TypeScript types.

Field validators

Convex provides validators for all JavaScript value types:

Primitive types

import { v } from "convex/values";

defineTable({
  // Strings
  name: v.string(),
  
  // Numbers (integers and floats)
  age: v.number(),
  price: v.float64(),
  
  // Booleans
  isActive: v.boolean(),
  
  // Null
  deletedAt: v.null(),
  
  // Bytes (for binary data)
  avatar: v.bytes(),
})

IDs and references

Use v.id() to reference documents in other tables:
defineTable({
  // Reference to a user document
  userId: v.id("users"),
  
  // Reference to a channel document
  channelId: v.id("channels"),
})
IDs are strongly typed - TypeScript will prevent you from mixing up IDs from different tables.

Arrays

defineTable({
  // Array of strings
  tags: v.array(v.string()),
  
  // Array of numbers
  scores: v.array(v.number()),
  
  // Array of user IDs
  memberIds: v.array(v.id("users")),
  
  // Nested arrays
  matrix: v.array(v.array(v.number())),
})

Objects

defineTable({
  // Object with known fields
  address: v.object({
    street: v.string(),
    city: v.string(),
    zipCode: v.string(),
  }),
  
  // Nested objects
  profile: v.object({
    bio: v.string(),
    social: v.object({
      twitter: v.optional(v.string()),
      linkedin: v.optional(v.string()),
    }),
  }),
})

Optional fields

Use v.optional() for fields that may not be present:
defineTable({
  name: v.string(),           // Required
  nickname: v.optional(v.string()),  // Optional
  age: v.optional(v.number()),       // Optional
})
Optional fields can be omitted when inserting documents or set to undefined when patching.

Unions

Model discriminated unions or multiple allowed types:
defineTable({
  // Simple union of types
  status: v.union(
    v.literal("pending"),
    v.literal("approved"),
    v.literal("rejected")
  ),
  
  // Discriminated union (recommended pattern)
  result: v.union(
    v.object({
      type: v.literal("success"),
      value: v.number(),
    }),
    v.object({
      type: v.literal("error"),
      message: v.string(),
    })
  ),
})

Any type

For fields with dynamic content (use sparingly):
defineTable({
  metadata: v.any(),  // Can be any valid Convex value
})
Avoid v.any() in production schemas when possible. It bypasses type safety and makes code harder to maintain. Prefer explicit unions or objects.

System fields

Every document automatically has two system fields that you don’t need to define:
  • _id - Unique document ID with type Id<"tableName">
  • _creationTime - Timestamp (milliseconds since epoch) when the document was created
These are added automatically and available in all documents:
const message = await ctx.db.get(messageId);
console.log(message._id);           // Id<"messages">
console.log(message._creationTime); // number (timestamp)

Defining indexes

Indexes make queries efficient by allowing fast lookups on specific fields. Define indexes with the .index() method:
export default defineSchema({
  messages: defineTable({
    body: v.string(),
    channelId: v.id("channels"),
    userId: v.id("users"),
    timestamp: v.number(),
  })
    // Simple index on one field
    .index("by_channel", ["channelId"])
    
    // Compound index on multiple fields
    .index("by_channel_timestamp", ["channelId", "timestamp"])
    
    // Index on multiple fields (order matters)
    .index("by_user_channel", ["userId", "channelId"]),
});

Index naming convention

Name indexes after their fields, prefixed with by_:
.index("by_email", ["email"])
.index("by_status_createdAt", ["status", "createdAt"])
.index("by_user_channel", ["userId", "channelId"])

Using indexes in queries

Indexes are used with .withIndex() in queries:
import { query } from "./_generated/server";
import { v } from "convex/values";

export const listChannelMessages = query({
  args: { channelId: v.id("channels") },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("messages")
      .withIndex("by_channel", (q) => 
        q.eq("channelId", args.channelId)
      )
      .order("desc")
      .take(50);
  },
});

Compound indexes

When using compound indexes, you must query fields in order:
// Schema
defineTable({
  status: v.string(),
  priority: v.string(),
  createdAt: v.number(),
}).index("by_status_priority_createdAt", ["status", "priority", "createdAt"])

// Valid queries
q.eq("status", "open")  // Uses index
q.eq("status", "open").eq("priority", "high")  // Uses index
q.eq("status", "open").eq("priority", "high").gt("createdAt", timestamp)  // Uses index

// Invalid query
q.eq("priority", "high")  // Cannot skip "status" field
If you need to query by different field combinations, create separate indexes.

Search indexes

Search indexes enable full-text search on string fields:
export default defineSchema({
  documents: defineTable({
    title: v.string(),
    content: v.string(),
    authorId: v.id("users"),
    category: v.string(),
  })
    .searchIndex("search_content", {
      searchField: "content",
      filterFields: ["authorId", "category"],
    }),
});
Search indexes allow you to:
  • Search text in the searchField
  • Filter results by filterFields for efficient queries
import { query } from "./_generated/server";
import { v } from "convex/values";

export const searchDocuments = query({
  args: {
    searchText: v.string(),
    category: v.string(),
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("documents")
      .withSearchIndex("search_content", (q) =>
        q.search("content", args.searchText)
         .eq("category", args.category)
      )
      .take(20);
  },
});

Vector indexes

Vector indexes enable similarity search for embeddings:
export default defineSchema({
  embeddings: defineTable({
    text: v.string(),
    embedding: v.array(v.float64()),
    category: v.string(),
  })
    .vectorIndex("by_embedding", {
      vectorField: "embedding",
      dimensions: 1536,  // Must match your embedding model
      filterFields: ["category"],
    }),
});
The dimensions must match the size of your embedding vectors (e.g., 1536 for OpenAI’s text-embedding-ada-002).

Schema validation

By default, Convex validates all data against your schema:
  1. On deployment - Checks all existing documents match the schema
  2. On write - Validates inserts and updates match the schema
If validation fails, the operation throws an error:
// This will throw an error if 'email' is not a string
await ctx.db.insert("users", {
  name: "Alice",
  email: 123,  // Type error!
});

Disabling validation

For rapid prototyping, you can disable schema validation:
export default defineSchema(
  {
    users: defineTable({
      name: v.string(),
      email: v.string(),
    }),
  },
  {
    schemaValidation: false,  // Disable runtime validation
  }
);
Only disable schema validation during prototyping. Always enable it for production apps to prevent data inconsistencies.

Strict table names

By default, TypeScript enforces that you only access tables defined in your schema. To disable this for prototyping:
export default defineSchema(
  {
    users: defineTable({
      name: v.string(),
    }),
  },
  {
    strictTableNameTypes: false,  // Allow accessing undefined tables
  }
);

Generated types

Convex automatically generates TypeScript types from your schema in convex/_generated/dataModel.d.ts:
import { Doc, Id } from "./_generated/dataModel";

// Use generated document types
function processUser(user: Doc<"users">) {
  console.log(user._id);        // Id<"users">
  console.log(user.name);       // string
  console.log(user.email);      // string
  console.log(user._creationTime);  // number
}

// Use generated ID types
function getUserId(): Id<"users"> {
  // Type-safe ID
}

Schema evolution

Schemas can evolve over time. Convex supports several patterns:

Adding optional fields

Safe - existing documents remain valid:
// Before
defineTable({
  name: v.string(),
})

// After - existing documents don't have 'bio'
defineTable({
  name: v.string(),
  bio: v.optional(v.string()),  // New optional field
})

Adding required fields

Requires migrating existing documents first:
// Step 1: Add as optional
defineTable({
  name: v.string(),
  email: v.optional(v.string()),
})

// Step 2: Run migration to populate field
// (migration code in a mutation)

// Step 3: Make required
defineTable({
  name: v.string(),
  email: v.string(),  // Now required
})

Removing fields

Make optional first, then remove:
// Step 1: Make optional
defineTable({
  name: v.string(),
  oldField: v.optional(v.string()),
})

// Step 2: Remove from schema
defineTable({
  name: v.string(),
  // oldField removed
})

Complex schema example

Here’s a complete schema for a chat application:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  users: defineTable({
    name: v.string(),
    email: v.string(),
    avatarUrl: v.optional(v.string()),
    status: v.union(
      v.literal("online"),
      v.literal("offline"),
      v.literal("away")
    ),
  })
    .index("by_email", ["email"]),
  
  channels: defineTable({
    name: v.string(),
    isPrivate: v.boolean(),
    memberIds: v.array(v.id("users")),
    createdBy: v.id("users"),
  })
    .index("by_createdBy", ["createdBy"]),
  
  messages: defineTable({
    body: v.string(),
    userId: v.id("users"),
    channelId: v.id("channels"),
    edited: v.optional(v.boolean()),
    reactions: v.optional(v.array(
      v.object({
        emoji: v.string(),
        userIds: v.array(v.id("users")),
      })
    )),
  })
    .index("by_channel", ["channelId"])
    .index("by_user", ["userId"])
    .searchIndex("search_body", {
      searchField: "body",
      filterFields: ["channelId"],
    }),
});

Next steps

Build docs developers (and LLMs) love