---
title: "Pixeltable vs Convex: measured video-intelligence comparison"
description: "Same app, two implementations. Pixeltable is 129 lines in one file; Convex is 681 with a compute service. Convex wins install and reactivity. Pixeltable wins in-platform media. A REST contract is Convex’s worst event."
keywords:
  - Pixeltable vs Convex
  - Convex alternative
  - Convex AI
  - Convex vector search
  - Python AI backend
  - TypeScript vs Python backend
url: "https://pixeltable.com/compare/pixeltable-vs-convex"
---

# Pixeltable vs Convex

Same video-intelligence app, two implementations. Pick Convex when reactivity is the point — the UI re-renders on write. Pick Pixeltable when the pipeline is the product. A REST-shaped benchmark is Convex’s worst event; discount this column accordingly.

## Summary

### Pixeltable

- 129 lines, one file: the pipeline is the schema
- ffmpeg, Whisper, and CLIP run in-process; no second service
- Declared HTTP routes; a missing query is a 422 before any handler runs
- Adding a column backfills in place; processing fires for any writer

### Convex

- 429 app lines plus 252 in compute-service, and the easiest install of the three
- npx convex dev: anonymous local backend, no account, no Docker
- 109 lines in videos.ts because an action cannot write to the database
- 90 lines in http.ts because this contract asked for REST instead of the reactive client

## Comparison

| Feature | Pixeltable | Convex |
| --- | --- | --- |
| Local install | pip install; large Python deps (torch, whisper, sentence-transformers) | npx convex dev — no account, no Docker, easiest of the three |
| Total code for this app | 129 lines, 1 file | 681 lines (429 + 252 compute-service), 7 files |
| ffmpeg, Whisper, CLIP | Computed columns, same process | compute-service; the Convex runtime cannot run them |
| Writes from an action | Insert is a row; computed columns run | An action cannot write; 109 lines of mutations in videos.ts |
| HTTP API | add_query_route derives the signature; malformed requests are 422 | Five http.route blocks. Validators sit inside the function; a failure is a 500 |
| Reactive client | None here — you would write polling or a websocket | The reason most teams pick Convex; discarded by this REST contract |
| Vector search result limit | No ceiling in this implementation | vectorSearch clamps to 256 |
| Ingest, 20 videos / 10 min | 66.7s, 9.04× realtime; first video 5.3s | 53.7s, 11.25× realtime; first video 3.2s |
| Transcript search p50 | 18.5ms | 11.8ms |
| Add a derived column (lines) | 1 line, 1 file | 53 lines, 2 files; reverting needs a second migration |
| Processing for any writer | Yes — the pipeline is the schema | No, unless you add a scheduled action |
| Vendor checker in CI | ruff only | @convex-dev/eslint-plugin and tsc --noEmit against generated code |

## Ingest a video

Insert a video. Frames, audio, transcripts, embeddings, and scenes have to exist after that. On Pixeltable they are the schema. On the other two they live in the ingest path and in a second service.

### Pixeltable

```python
class Videos(TableModel, name='videos'):
    video: pxt.Video
    title: pxt.String
    audio = extract_audio(video, format='mp3')
    duration_sec = pxtf.video.get_duration(video)
    scenes = video.scene_detect_content(threshold=8.0)

class Frames(TableModel, name='frames', base=Videos,
             iterator=frame_iterator(Videos.video, fps=1.0)):
    still = pxtf.image.resize(frame, (320, 180))
    __indexes__ = [pxt.EmbeddingIndex(frame, embedding=VISUAL)]

class Chunks(TableModel, name='chunks', base=Videos,
             iterator=audio_splitter(Videos.audio, duration=10.0)):
    transcript = transcribe(audio_segment, model='base.en').text.astype(pxt.String)
    __indexes__ = [pxt.EmbeddingIndex(transcript, embedding=SEMANTIC)]

Videos.insert([{'video': 'lecture.mp4', 'title': 'CS101'}])
```

### Convex

```typescript
const { frames } = await compute("/extract-frames", { video_url, fps: FRAME_FPS });
const { embeddings } = await compute("/embed-clip", { images_b64: frames });
const frameRows = await Promise.all(frames.map(async (b64, i) => ({
  frameIdx: i,
  imageStorageId: await ctx.storage.store(new Blob([decodeBase64(b64)])),
  embedding: embeddings[i],
})));
await ctx.runMutation(internal.videos.insertFrames, { videoId, rows: frameRows });
```

## Search frames

Find frames of a whiteboard. Pixeltable asks the index. The other two embed the query themselves, then join or fetch rows in a second step.

### Pixeltable

```python
sim = Frames.frame.similarity(string=query)
return (
    Frames.order_by(sim, asc=False)
    .limit(limit)
    .select(
        frame_url=Frames.still,
        frame_idx=Frames.pos,
        video_title=Frames.title,
        similarity=sim,
    )
)
```

### Convex

```typescript
const { embeddings } = await compute("/embed-clip", { texts: [query] });
const hits = await ctx.vectorSearch("frames", "by_embedding", {
  vector: embeddings[0],
  limit: clamp(limit), // vectorSearch is 1-256
});
return await ctx.runQuery(internal.search.framesByIds, {
  ids: hits.map((h) => h._id),
  scores: hits.map((h) => h._score),
});
```

## Serve over HTTP

Expose search over HTTP. Pixeltable derives the route from the query. Supabase puts five paths in one function. Convex writes five REST routes only because this contract asked for REST.

### Pixeltable

```python
api = FastAPIRouter(name='api')
api.add_insert_route(Videos, path='/videos', inputs=[Videos.video, Videos.title], background=True)
api.add_query_route(path='/videos', query=list_videos, method='get')
api.add_query_route(path='/search/frames', query=search_frames, method='post')
api.add_query_route(path='/search/transcripts', query=search_transcripts, method='post')
```

### Convex

```typescript
http.route({
  path: "/search/frames",
  method: "POST",
  handler: httpAction(async (ctx, req) =>
    guarded(async (r) => {
      const body = await readJson(r);
      return json(await ctx.runAction(api.search.searchFrames, {
        query: requireString(body.query, "query"),
        limit: readLimit(body.limit),
      }));
    })(req)
  ),
});
```

## When to choose Pixeltable

- **The pipeline is the product**: Media in, models and retrieval out, in one Python file. Processing belongs to the table, so a row written from a shell is processed the same way as a row written over HTTP.
- **You do not want to operate a second runtime for ffmpeg**: The Convex runtime cannot execute ffmpeg. Three compute-service endpoints have no hosted-API substitute. That extra service is most of the orchestration hops.

## When to choose Convex

- **Reactivity is the point**: Build the same app with Convex’s reactive client instead of five REST endpoints and http.ts disappears along with both taxes. The client re-renders on write for free, and mutations are transactional.
- **You want the easiest local backend**: npx convex dev gives a working local backend with no account and no Docker. That is the easiest install of the three, and it is not close.

## FAQ

### Should I replace Convex with Pixeltable?

Not if you picked Convex for reactivity. Pixeltable has no realtime push in this benchmark. Many teams keep Convex for the app and put media and retrieval in Pixeltable.

### Why is Convex’s line count so high?

429 app lines, 681 with compute-service. Most of the app tax is two things this contract imposes: an action cannot write to the database (videos.ts), and we asked for REST (http.ts). A reactive-client implementation would not pay either.

### Is Pixeltable faster?

All three beat realtime on this laptop. Convex ingests in 53.7s against Pixeltable’s 66.7s — about 13 seconds on ten minutes of footage — and transcript search is 12ms vs 19ms. Most of search is embedding the query. This is CPU and local models, not Cloud. The durable Pixeltable claims are structural: where media processing runs, what happens when you add a column, and that processing fires for any writer.

