---
title: "Convex Developers' Guide to Pixeltable: Queries, Mutations, Actions, and Where the Mental Models Diverge"
date: "2026-06-20"
author: "Pierre Brunelle"
tags:
  - Convex
  - Full-Stack
  - Developer Experience
  - AI Infrastructure
  - Python
  - Declarative
  - Queries
  - Mutations
  - Architecture
description: "If you know Convex (queries, mutations, actions, and reactive subscriptions), this guide maps those primitives onto Pixeltable. We use the help-desk mental model from Database School's Convex course as a parallel walkthrough, with side-by-side code and an honest cheat sheet for full-stack developers building AI-heavy backends."
url: "https://pixeltable.com/blog/convex-developers-guide-to-pixeltable"
---

# Convex Developers' Guide to Pixeltable: Queries, Mutations, Actions, and Where the Mental Models Diverge

**Summary:** Convex gives full-stack developers a reactive TypeScript backend: **queries** read, **mutations** write in transactions, **actions** call the outside world. Pixeltable gives AI developers a declarative data plane: tables, **computed columns**, embedding indexes, and `@pxt.query` functions. This post maps Convex primitives onto Pixeltable using the help-desk app from [Database School's Convex course](https://databaseschool.com/series/convex) and the [Convex docs overview](https://docs.convex.dev/home). It is a translation guide, not a "replace Convex" pitch, structured so we can later extract sections into official docs.

 
## Two Stacks, Same Shape, Different Center of Gravity

 
Convex's architecture is three layers: a **document-relational database**, **server functions** (query / mutation / action), and **client libraries** that keep React in sync via WebSockets. Pixeltable's architecture is also three layers, but the guarantees differ:

 

 - **Convex** keeps your frontend and backend *consistently reactive*: when data changes, subscribed queries rerun and the UI updates.

 - **Pixeltable** keeps your data and AI transformations *consistently derived*: when source data changes, computed columns and embedding indexes update incrementally.

 

 
Convex is an excellent **application coordination layer** for TypeScript full-stack apps. Pixeltable is an excellent **AI data layer** for multimodal storage, model orchestration, and retrieval. Many production systems need both; AI-heavy backends often reimplement Convex's query/mutation/action split in Python glue. Pixeltable collapses the data + AI side into the schema.

 
For a feature matrix against Supabase and Convex, see our [Compare AI backends](/compare) page. For the broader "schema IS infrastructure" thesis, see [Schema-Driven Infrastructure](/blog/schema-driven-infrastructure-ai).

 
## 1. Database Layer

 
Convex is a document-relational database: JSON-like documents in tables, relations via `v.id()` references, validators at the boundary, and ACID transactions with serializable isolation. Pixeltable is a multimodal table store: typed columns, computed expressions, and native media types.

 
| Convex | Pixeltable | Notes |
| --- | --- | --- |
| defineSchema + validators | pxt.create_table({ ... }) | Types enforced at insert boundary |
| Documents + v.id() relations | Tables + ID columns (workspace_id, ticket_id) | Relational model; unbounded collections (messages) get their own table |
| v.union status enum | pxt.String + validation in write path | State machines enforced in mutation-equivalent code |
| Reactive query invalidation | Incremental recomputation | Derived data stays fresh; not live WebSocket push |
| File storage IDs | pxt.Image, pxt.Video, pxt.Document | Media is first-class in the schema |

 
### Parallel walkthrough: help-desk data model

 
Database School's help desk models **workspaces** (tenants), **customers**, **tickets**, and **messages** as separate tables, not embedded arrays. That lesson applies directly in Pixeltable:

 
```python
import pixeltable as pxt

workspaces = pxt.create_table('helpdesk.workspaces', {
 'slug': pxt.String,
 'name': pxt.String,
})

customers = pxt.create_table('helpdesk.customers', {
 'customer_id': pxt.String,
 'workspace_id': pxt.String,
 'email': pxt.String,
 'name': pxt.String,
})

tickets = pxt.create_table('helpdesk.tickets', {
 'ticket_id': pxt.String,
 'workspace_id': pxt.String,
 'customer_id': pxt.String,
 'number': pxt.Int,
 'subject': pxt.String,
 'description': pxt.String,
 'status': pxt.String, # open | pending | resolved | closed
 'assignee_id': pxt.String,
 'updated_at': pxt.Timestamp,
})

messages = pxt.create_table('helpdesk.messages', {
 'ticket_id': pxt.String,
 'author_type': pxt.String, # agent | customer | system
 'body': pxt.String,
 'created_at': pxt.Timestamp,
})
```

 
Model multi-tenancy early via `workspace_id` on every tenant-scoped table. That is the same advice Aaron gives in the Convex course, applied here with explicit columns instead of Convex validators.

 
## 2. Server Functions: Queries, Mutations, Actions

 
This is the core mapping. Convex splits backend logic into three function types with strict rules. Pixeltable splits logic into **reads**, **writes**, and **derived computation**, with a different default for external side effects.

 
### Queries (read-only)

 
Convex queries are pure TypeScript: they read the database, cannot call the network, and their results can be subscribed to via `useQuery`. Pixeltable reads are table expressions or `@pxt.query` functions exposed over HTTP.

 
| Convex | Pixeltable |
| --- | --- |
| query({ handler: (ctx) => ctx.db.query("tickets").withIndex(...).collect() }) | tickets.select(...).where(...).order_by(...).collect() |
| Cached + live-subscribable | Read-only by convention; serve via FastAPIRouter and pxt service update |
| Screen-specific BFF query (getThread) | @pxt.query for single-table reads; plain Python for multi-table assembly |

 
**Example: list open tickets by workspace (index-backed read)**

 
```typescript
// Convex: convex/tickets.ts
export const listOpenByWorkspace = query({
 args: { workspaceId: v.id("workspaces") },
 handler: async (ctx, { workspaceId }) => {
 return await ctx.db
 .query("tickets")
 .withIndex("by_workspace_status_updated", (q) =>
 q.eq("workspaceId", workspaceId).eq("status", "open")
 )
 .order("desc")
 .take(50);
 },
});
```

 
```python
# Pixeltable: helpdesk/queries.py
import pixeltable as pxt

tickets = pxt.get_table('helpdesk.tickets')

@pxt.query
def list_open_by_workspace(workspace_id: str):
 return (
 tickets
 .where(
 (tickets.workspace_id == workspace_id) &
 (tickets.status == 'open')
 )
 .order_by(tickets.updated_at, asc=False)
 .limit(50)
 .select(tickets.number, tickets.subject, tickets.updated_at)
 )
```

 
Convex explicitly chooses an index with `withIndex`. Pixeltable filters and sorts on columns; for vector paths you add `add_embedding_index` and use `.similarity()` instead.

 
### Mutations (transactional writes)

 
Convex mutations are transactions: the entire handler commits or rolls back, with serializability and optimistic concurrency control. Pixeltable writes are `insert`, `update`, and `delete` on tables, with versioning and incremental recompute for downstream computed columns.

 
| Convex | Pixeltable |
| --- | --- |
| mutation({ handler: (ctx) => ctx.db.patch(...) }) | t.insert([...]), t.update(...), t.delete(...) |
| Automatic OCC retries on conflict | Server-side guards + versioning |
| State machine in mutation | Validate transition in Python before update; insert system message in same logical batch |
| Denormalized search row in same mutation | Computed search_text column updates automatically on insert |

 
**Example: assign ticket with concurrent-write guard**

 
```typescript
// Convex: assignTicket mutation
export const assignTicket = mutation({
 args: { ticketId: v.id("tickets"), assigneeId: v.id("users") },
 handler: async (ctx, { ticketId, assigneeId }) => {
 const ticket = await ctx.db.get(ticketId);
 if (ticket.assigneeId) {
 throw new Error("Ticket already assigned");
 }
 await ctx.db.patch(ticketId, { assigneeId, status: "pending" });
 await ctx.db.insert("messages", {
 ticketId,
 authorType: "system",
 body: `Assigned to ${assigneeId}`,
 });
 },
});
```

 
```python
# Pixeltable: assign via update + insert (app-layer guard)
# Unlike a Convex mutation, update and insert are separate calls.
def assign_ticket(ticket_id: str, assignee_id: str) -> None:
 row = tickets.where(tickets.ticket_id == ticket_id).collect()[0]
 if row['assignee_id']:
 raise ValueError('Ticket already assigned')
 tickets.update({'assignee_id': assignee_id, 'status': 'pending'},
 where=tickets.ticket_id == ticket_id)
 messages.insert([{
 'ticket_id': ticket_id,
 'author_type': 'system',
 'body': f'Assigned to {assignee_id}',
 }])
```

 
Convex encodes the product decision inside the mutation and relies on OCC so two simultaneous assigns serialize cleanly. In Pixeltable you enforce the same rule in application code (or a `@pxt.udf` wrapper) before writing. The lesson from Database School still applies: **do not trust the client with business rules**.

 
### Actions (external side effects)

 
Convex actions call the network (OpenAI, email, webhooks) and must call a mutation to persist results. Pixeltable often collapses this split: external model calls live in **computed columns**, and results persist automatically when rows change.

 
| Convex | Pixeltable |
| --- | --- |
| action → OpenAI, Resend, webhooks | @pxt.udf or computed column calling openai.* / huggingface.* |
| Must call mutation to persist | Computed column persists on insert/update automatically |
| scheduler.runAfter(0, internalAction) | Incremental engine runs after commit; no manual scheduler for derived columns |
| internalAction | Non-exposed UDF / query; FastAPIRouter controls public HTTP surface |

 
**Key insight for Convex developers:** many Convex "actions" become Pixeltable **computed columns**. The durable write and the side effect are declared together, not split across action → mutation.

 
**Example: generate embeddings on ticket create**

 
```typescript
// Convex: schedule embedding after ticket mutation commits
export const createTicket = mutation({ /* insert ticket */ });

export const embedTicket = internalAction({
 args: { ticketId: v.id("tickets") },
 handler: async (ctx, { ticketId }) => {
 const ticket = await ctx.runQuery(internal.tickets.get, { ticketId });
 const text = ticket.subject + " " + ticket.description;
 const embedResp = await fetch("https://api.openai.com/v1/embeddings", {
 method: "POST",
 headers: {
 "Content-Type": "application/json",
 Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
 },
 body: JSON.stringify({ model: "text-embedding-3-small", input: text }),
 });
 const { data } = await embedResp.json();
 await ctx.runMutation(internal.ticketSearch.upsert, {
 ticketId,
 embedding: data[0].embedding,
 });
 },
});
// In createTicket: await scheduler.runAfter(0, internal.tickets.embedTicket, { ticketId });
```

 
```python
# Pixeltable: declarative embedding index (no separate action/mutation chain)
from pixeltable.functions.openai import embeddings

tickets.add_computed_column(
 search_text=tickets.subject + ' ' + tickets.description,
)

tickets.add_embedding_index(
 'search_text',
 embedding=embeddings.using(model='text-embedding-3-small'),
)
# New tickets: search_text computed, then embedding indexed incrementally
```

 
See [Incremental Embedding Indexes](/blog/pixeltable-incremental-embedding-indexes) for why this matters at scale. Our [compare hub](/compare) walks through the same video-intelligence pipeline in 129 lines on Pixeltable vs 671 on Convex + external compute. For a runnable side-by-side implementation, see the [pixeltable-vs-supabase-vs-convex](https://github.com/pixeltable/pixeltable-vs-supabase-vs-convex) repo.

 
### BFF query: getThread

 
Database School's ticket detail screen uses a single `getThread` query that assembles workspace, ticket, customer, and messages server-side: backend-for-frontend without a separate BFF layer. In Pixeltable, use `@pxt.query` for single-table reads and a plain Python function for multi-table assembly (the same split Convex uses with `getThread` plus `internalQuery` helpers):

 
```python
@pxt.query
def list_messages(ticket_id: str):
 return (
 messages
 .where(messages.ticket_id == ticket_id)
 .order_by(messages.created_at, asc=True)
 .select(messages.author_type, messages.body, messages.created_at)
 )

def get_thread(ticket_id: str) -> dict:
 t = tickets.where(tickets.ticket_id == ticket_id).select(
 tickets.number, tickets.subject, tickets.status, tickets.workspace_id,
 tickets.customer_id,
 ).collect()[0]
 cust = customers.where(customers.customer_id == t['customer_id']).select(
 customers.name, customers.email,
 ).collect()[0]
 msgs = list_messages(ticket_id).collect()
 return {'ticket': t, 'customer': cust, 'messages': msgs}
```

 
```python
from pixeltable.serving import FastAPIRouter

router = FastAPIRouter(prefix='/helpdesk')
router.add_query_route(path='/thread/messages', query=list_messages)
# Multi-table BFF: expose get_thread in a FastAPI route handler
```

 
The frontend gets one round trip with the exact shape it needs. Single-table slices stay reusable via `@pxt.query`; multi-table screens compose them in plain Python at the serving layer.

 
## 3. Indexes and Access Patterns

 
| Convex | Pixeltable |
| --- | --- |
| .index("by_workspace", ["workspaceId", "updatedAt"]) | Filter/sort columns; compound access via .where().order_by() |
| withSearchIndex on denormalized field | Computed search_text + string or embedding index |
| vectorIndex + manual embed action | add_embedding_index(): embed + index incrementally |

 
Convex compound indexes follow the left-prefix rule: an index on `[A, B, C]` serves queries on `A`, `A+B`, and `A+B+C`. In Pixeltable, express the same access path as filters and sorts on those columns. For semantic search over tickets, use `search_text.similarity(string=query)` after adding an embedding index.

 
## 4. Client Layer: The Honest Gap

 
Convex's `useQuery` is a major developer-experience win: live subscriptions, consistent multi-query snapshots, and no manual WebSocket wiring. Pixeltable does not ship a React hook. You serve data over HTTP and let the frontend poll, use SSE, or wrap endpoints in a Next.js API route.

 
| Convex | Pixeltable |
| --- | --- |
| useQuery WebSocket subscription | HTTP via FastAPIRouter / pxt service update / Pixeltable Cloud dashboard |
| Optimistic updates built into client | Frontend concern; not in the data layer |
| All subscribed queries update at same logical time | Consistent per query; app composes multi-fetch |

 
**Bridge pattern:** keep Convex (or Next.js) for reactive UI and auth; call Pixeltable for AI retrieval and multimodal pipelines. See our [PixelAssist retrospective](/blog/pixelassist-retrospective-typescript-vs-pixeltable-backend) for a real Next.js → data-layer split.

 
## 5. Production Patterns (Database School Module 5)

 
| Course lesson | Convex | Pixeltable |
| --- | --- | --- |
| Cron jobs | crons.interval | External cron (e.g. Vercel) hitting API; or insert-triggered incremental compute |
| Durable workflows | Convex Workflows | Computed column DAG; see dependency graph |
| Rate limits | Rate limiter component | App-side rate limiting / serve middleware |
| Vector search | vectorSearch action | column.similarity(string=query) |
| AI suggest reply | Action + rate limit + OpenAI | @pxt.query RAG over ticket + similar tickets via embedding index |

 
## 6. Cheat Sheet (docs-ready)

 
| Convex primitive | Pixeltable equivalent | When to use which |
| --- | --- | --- |
| query | @pxt.query / select().collect() | Convex for live UI reads; Pixeltable for AI retrieval reads |
| mutation | insert / update / delete | Both for durable writes; Convex adds OCC + live invalidation |
| action | Computed column or @pxt.udf | Pixeltable when side effect is derived data (embed, transcribe, classify) |
| scheduler | Incremental engine (derived) or external cron (app) | Pixeltable for pipeline steps; cron for calendar-time jobs |
| Schema / validators | create_table + add_computed_column | Pixeltable schema also declares AI compute |
| Index | Column filters + add_embedding_index | Pixeltable for semantic / multimodal search |
| BFF query | @pxt.query or plain Python for multi-table BFF | Same pattern: compose reads server-side |
| Auth / tenancy | workspace_id + serve-layer auth | Convex stronger for app auth; Pixeltable for data-plane isolation |
| Live UI (useQuery) | HTTP serving | Convex wins for reactive TypeScript apps |

 
## 7. When to Use Convex, Pixeltable, or Both

 
### Choose Convex when

 

 - You are building a TypeScript full-stack app with live-updating UI

 - Auth, roles, invitations, and optimistic updates are core product features

 - Your backend is mostly CRUD + coordination, not heavy ML pipelines

 

 
### Choose Pixeltable when

 

 - Your backend is an AI data plane: embeddings, transcription, classification, multimodal RAG

 - You need incremental recomputation when source data changes, not manual backfills

 - You want one schema to declare storage, compute, indexes, and serving ([transformations in the schema](/blog/ai-transformations-in-the-schema))

 

 
### Use both when

 

 - Convex handles reactive ticket UI, auth, and real-time presence

 - Pixeltable handles ticket embeddings, similar-ticket search, attachment transcription, and agent-suggested replies

 - The Convex action calls Pixeltable's HTTP query endpoint for retrieval; each system does what it is best at

 

 
## Learn More

 

 - [Compare AI backends](/compare): Pixeltable vs Supabase vs Convex feature matrix

 - [Schema-Driven Infrastructure](/blog/schema-driven-infrastructure-ai): the Vercel analogy for AI backends

 - [Incremental Embedding Indexes](/blog/pixeltable-incremental-embedding-indexes): why action→mutation embed chains become one declaration

 - [Pixeltable Core Concepts](/blog/pixeltable-core-concepts): computed columns and incremental execution

 - [pixeltable-vs-supabase-vs-convex repo](https://github.com/pixeltable/pixeltable-vs-supabase-vs-convex): runnable Convex vs Pixeltable multimodal pipeline

 - [Database School: Convex series](https://databaseschool.com/series/convex): the help-desk course that inspired this mapping

 - [Convex docs overview](https://docs.convex.dev/home): queries, mutations, actions, and reactivity