Skip to main content
skill CX Codex

convex-schema-validator — Claude Code skill: install and use

Designs Convex tables, validators, indexes, and document relationships in convex/schema.ts. Use it when a schema changes or validation fails during development.

Install into Claude Code · Skills CLI (Vercel) All methods and platforms ↓
npx skills add waynesutton/builder-skills --skill convex-schema-validator -a claude-code
Open on GitHub

What convex-schema-validator does

This skill works on convex/schema.ts to define Convex tables, field validators, indexes, and relationships. It can guide a new table or field change, model documents with different shapes through v.union, and address a validation error from npx convex dev.

Each withIndex call needs a matching .index() whose name and field order follow the query. Equality conditions use an index prefix, followed by at most one range condition on the next field. A .filter() in a query is a reason to check whether the query should use an index and withIndex instead.

It distinguishes missing fields with v.optional from fields that must exist but may contain null. Relationships use v.id("table"); Convex does not verify that the referenced document exists, so write logic needs to account for that.

What it helps with

  • define a table in convex/schema.ts
  • add a field to a table with existing documents
  • match withIndex calls to .index() declarations
  • replace a .filter() query with withIndex
  • model a relationship with v.id("table")

Example prompts for the agent

  • Review convex/schema.ts and add indexes for every withIndex call.
  • Add a field to an existing Convex table without breaking validation for older data.
  • Replace this .filter() query with withIndex and declare the required index.

How to install convex-schema-validator

1Skills CLI (Vercel)

npx skills add waynesutton/builder-skills --skill convex-schema-validator -a claude-code

2Manually: put the files in place

~/.claude/skills/convex-schema-validator/SKILL.md

Copy the whole skill folder; for one project use .claude/skills/convex-schema-validator/

Step by step, with commands for each platform: How to install skills in Claude Code: five ways

Questions about convex-schema-validator

How do I install convex-schema-validator in Claude Code?

Run npx skills add waynesutton/builder-skills --skill convex-schema-validator -a claude-code, then run npx skills add waynesutton/builder-skills --skill convex-schema-validator -a claude-code again. The skill file is located at ~/.claude/skills/convex-schema-validator/SKILL.md.

What does convex-schema-validator do?

It guides the design of Convex tables, validators, indexes, and relationships in convex/schema.ts. It also helps trace schema validation errors during development.

What is the difference between v.optional and null?

v.optional(t) allows a document to omit the field. v.union(t, v.null()) requires the field to be present, while allowing its value to be null.

Source text

SKILL.md · Apache-2.0
---
name: convex-schema-validator
description: Designs convex/schema.ts tables, validators, indexes, and relationships, and keeps the schema honest as data evolves. Use when creating tables, adding fields, choosing index fields, modeling relationships, or when a validator error appears at deploy time.
---

# Convex schema validator

Produces a `convex/schema.ts` that types every document, indexes every query path, and passes validation against the data already in the database. The one rule: every `withIndex` in a function needs a matching `.index()` here, named after its fields, queried in field order.

## When to reach for this

- Creating a new table or adding a field to an existing one
- A query uses `.filter()` and needs an index instead
- Deciding whether to embed an object or link with `v.id`
- Modeling a document that comes in several shapes
- `npx convex dev` fails with a schema validation error

## Schema skeleton

```typescript
// 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(),
    avatarUrl: v.optional(v.string()),
  }).index("by_email", ["email"]),

  tasks: defineTable({
    userId: v.id("users"),
    title: v.string(),
    completed: v.boolean(),
    priority: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
  })
    .index("by_userId", ["userId"])
    .index("by_userId_and_completed", ["userId", "completed"]),
});
```

`defineTable` takes either an object of field validators or a single `v.union` of `v.object` validators (see discriminated unions). Every table gets `_id` and `_creationTime` for free. Do not declare them.

## Validators

| Validator | TypeScript type | Note |
| --- | --- | --- |
| `v.string()` | `string` | UTF-8, under 1 MB |
| `v.number()` | `number` | Float64. Use for timestamps and counts |
| `v.boolean()` | `boolean` | |
| `v.null()` | `null` | `undefined` is not a Convex value. Return `null` instead |
| `v.int64()` | `bigint` | Not `v.bigint()`, which is deprecated |
| `v.bytes()` | `ArrayBuffer` | Under 1 MB |
| `v.id("table")` | `Id<"table">` | Typed reference. Convex does not enforce that the target exists |
| `v.array(t)` | `T[]` | At most 8192 items |
| `v.object({...})` | `{...}` | At most 1024 entries. Keys cannot start with `$` or `_` |
| `v.record(k, t)` | `Record<K, T>` | Dynamic ASCII keys. No `v.map` or `v.set` |
| `v.union(a, b)` | `A \| B` | Use `v.literal` members for enums |
| `v.literal("x")` | `"x"` | |
| `v.optional(t)` | `T \| undefined` | Field may be absent |
| `v.any()` | `any` | Last resort. Loses type safety and validation |

## Optional versus nullable

`v.optional` means the key may be missing from the document. `v.union(t, v.null())` means the key is always present and may hold `null`. They are different at validation time.

```typescript
items: defineTable({
  description: v.optional(v.string()),           // may be absent
  deletedAt: v.union(v.number(), v.null()),       // always present, may be null
  notes: v.optional(v.union(v.string(), v.null())), // either
}),
```

Use `v.optional` for fields added after the table had data. Use `v.union(..., v.null())` when "explicitly cleared" carries meaning.

## Discriminated unions

For a table whose documents come in several shapes, pass a `v.union` of `v.object` validators to `defineTable`. Each member has the same literal key so TypeScript narrows on it.

```typescript
events: defineTable(
  v.union(
    v.object({
      kind: v.literal("signup"),
      userId: v.id("users"),
      email: v.string(),
    }),
    v.object({
      kind: v.literal("purchase"),
      userId: v.id("users"),
      orderId: v.id("orders"),
      amount: v.number(),
    }),
  ),
).index("by_kind", ["kind"]),
```

Put the discriminant (`kind`) first in any index on a union table so queries can scope to one shape. Prefer this over one wide object full of `v.optional` fields.

## Indexes

### Naming and field order

Name the index after its fields in order: `by_field1_and_field2`. Querying must follow the same order: equality on a prefix of the fields, then at most one range on the next field.

```typescript
messages: defineTable({
  channelId: v.id("channels"),
  authorId: v.id("users"),
  sentAt: v.number(),
})
  .index("by_channelId", ["channelId"])
  .index("by_channelId_and_authorId", ["channelId", "authorId"])
  .index("by_channelId_and_sentAt", ["channelId", "sentAt"]),
```

```typescript
// Valid: equality on channelId, range on sentAt
await ctx.db
  .query("messages")
  .withIndex("by_channelId_and_sentAt", (q) =>
    q.eq("channelId", args.channelId).gt("sentAt", args.since),
  )
  .order("desc")
  .take(50);
```

You cannot skip `channelId` and filter on `sentAt` alone with that index. Add `by_sentAt` if that query exists. `_creationTime` is appended to every index automatically, so results within an equal prefix sort by creation time.

Reserved names: `by_id` and `by_creation_time`. Limits: 32 indexes per table, 16 fields per index.

### When to add one

- Any field a function passes to `withIndex`, `.eq`, or a range comparison
- Every foreign key (`userId`, `channelId`, `orgId`) on the child table
- The sort field for a paginated list, prefixed by the scoping field
- Not for fields you only read after fetching the document
- Not for tiny tables where a `.collect()` then in memory filter is fine

If a query uses `.filter()`, that is the signal to add an index and switch to `withIndex`.

## Relationships

Link documents with `v.id("table")` on the child. Do not nest growing arrays of objects inside the parent.

```typescript
// Good: one to many via a foreign key
posts: defineTable({
  authorId: v.id("users"),
  title: v.string(),
}).index("by_authorId", ["authorId"]),

comments: defineTable({
  postId: v.id("posts"),
  authorId: v.id("users"),
  body: v.string(),
}).index("by_postId", ["postId"]),

// Many to many via a join table
postTags: defineTable({
  postId: v.id("posts"),
  tagId: v.id("tags"),
})
  .index("by_postId", ["postId"])
  .index("by_tagId", ["tagId"]),
```

Embed with `v.object` or a small `v.array` only when the data is bounded, always loaded with the parent, and updated together. A user's `settings` object is a good embed. A user's `posts` array is not: it hits the 8192 item cap and every post edit rewrites the user document.

## System fields

`_id: Id<"table">` and `_creationTime: number` (ms since epoch) exist on every document. Include them in return validators when a function returns whole documents:

```typescript
returns: v.array(
  v.object({
    _id: v.id("tasks"),
    _creationTime: v.number(),
    userId: v.id("users"),
    title: v.string(),
    completed: v.boolean(),
  }),
),
```

Do not add your own `createdAt` unless you need a value that differs from insertion time.

## Search and vector indexes

Declared on the table like regular indexes. `filterFields` must be top level fields.

```typescript
articles: defineTable({
  title: v.string(),
  body: v.string(),
  category: v.string(),
  embedding: v.array(v.number()),
})
  .searchIndex("search_body", {
    searchField: "body",
    filterFields: ["category"],
  })
  .vectorIndex("by_embedding", {
    vectorField: "embedding",
    dimensions: 1536,
    filterFields: ["category"],
  }),
```

Query search indexes with `withSearchIndex` in queries. Vector search runs only in actions via `ctx.vectorSearch`.

## Common mistakes

| Mistake | Why it breaks | Do instead |
| --- | --- | --- |
| `withIndex("by_userId")` with no matching `.index()` | Deploy fails | Declare the index in the schema first |
| Index named `by_user` on `["userId", "status"]` | Hides what it covers, easy to misuse | `by_userId_and_status` |
| Querying `status` on `by_userId_and_status` without `userId` | Index prefix rule | Add `by_status` or include `userId` |
| Adding `newField: v.string()` to a table with rows | Existing documents fail validation | `v.optional`, backfill, then require |
| `posts: v.array(v.object(...))` on `users` | 8192 cap, write conflicts on every edit | Separate `posts` table with `by_authorId` |
| `v.bigint()` | Deprecated | `v.int64()` |
| Declaring `_id` or `_creationTime` in `defineTable` | Rejected | They are automatic |
| `v.any()` to move fast | No validation, no types | Model the shape, or a `v.union` of the real cases |
| `v.union(v.string(), v.null())` for a new field | Old documents lack the key entirely | `v.optional(v.string())` |
| Storing a `Date` | Not a Convex value | `v.number()` ms timestamp |

## Checklist

- [ ] Schema lives in `convex/schema.ts` and exports `defineSchema(...)` as default
- [ ] Every table has explicit field validators, no `v.any()` unless justified
- [ ] Every `withIndex` call in `convex/` has a matching `.index()` with fields in the name
- [ ] Foreign keys are `v.id("table")` with an index on the child table
- [ ] No unbounded arrays of objects embedded in a parent document
- [ ] Fields added to tables with data are `v.optional`
- [ ] Enums and polymorphic shapes use `v.union` of `v.literal` or `v.object` members
- [ ] Return validators include `_id` and `_creationTime` when returning whole documents
- [ ] `npx convex dev` pushes without a schema validation error

## Docs

- https://docs.convex.dev/llms.txt
- https://docs.convex.dev/database/schemas
- https://docs.convex.dev/database/indexes
- https://docs.convex.dev/database/types
- https://docs.convex.dev/database/reading-data

© waynesutton · Apache-2.0 · waynesutton/builder-skills

Similar skills

All skills for Data and databases →

From the same set14

convex Routes Convex backend tasks to dedicated builder skills and enforces global rules for queries, mutations, and schemas. Useful as the starting point for any Convex project. CX Codex +1 5,700installs convex-best-practices Guides your agent through production Convex patterns, from index design to OCC conflict prevention. Essential for clean mutations, schema validation, and fast queries. CX Codex +1 3,900installs convex-functions Writes Convex queries, mutations and actions in the object form, with args and returns validators, correct ctx usage and runtime boundaries. CX Codex +1 3,200installs convex-realtime A skill for wiring React to Convex subscriptions: useQuery patterns, optimistic updates, pagination that stays live, and cutting subscription churn. CX Codex +1 2,900installs convex-cron-jobs Sets up recurring jobs in convex/crons.ts and delayed execution with ctx.scheduler. Helps batch heavy mutations and debug failing queues in Convex. CX Codex +1 2,800installs convex-security-check Checks a Convex backend before merge for exposed functions, missing auth, weak validation, ownership gaps, and secrets in code. CX Codex +1 2,700installs convex-http-actions Adds HTTP endpoints in convex/http.ts: webhook receivers with signature checks, REST routes, CORS, streaming and file uploads. CX Codex +1 2,700installs convex-migrations Walks a live Convex schema change through six steps: optional field, batched backfill, validator flip, cleanup. Useful when npx convex dev rejects existing documents. CX Codex +1 2,600installs

Project sponsors

Companies that keep this analytics open
Gold sponsor

Impressions, clicks and code copies — open statistics. We keep the numbers public for the same reason NeBlask itself is public. Become a sponsor