Pixeltable vs Supabase vs Convex
Same video-intelligence app, three implementations: ingest, frames, transcripts, embeddings, scenes, an agent. Pixeltable is the database, the orchestration, and the serving in one Python file. Insert a row. Transforms run.
Measured from the source
The app is media-heavy, which suits Pixeltable. Convex is the easiest local install and the fastest read path. Supabase ingests fastest at both measured tiers and adds a column to live data in the least wall time. Pixeltable is last on serial search by single-digit milliseconds, about 3x faster on the agent query, and slowest under concurrent load: same cause, models in the request path. None of this is a Cloud run. Every number is produced by reading the source or running the harness; n/a means the metric does not apply, never that it scored zero.
| Feature | Pixeltable | Supabase | Convex |
|---|---|---|---|
| App code you maintain | 129 one Python file | 302 SQL + TypeScript | 425 TypeScript |
| Plus the shared compute service | 0 ffmpeg, Whisper, CLIP run in-process | 246 Deno cannot run ffmpeg | 246 Convex runtime cannot run ffmpeg |
| Total | 129 | 548 | 671 |
| Files you open to read the backend | 1 | 7 | 7 |
| Services you operate | 1 | 2 | 2 |
| Orchestration hops | 3 one hand-written route | 12 from / storage / rpc | 9 runMutation / runAction / scheduler |
| HTTP routes written by hand | 1 the agent; queries are declared | 1 one Edge Function, five paths | 5 REST tax: http.ts is 90 lines |
Non-blank, non-comment lines. Lock files counted nowhere. Regenerated by harness/run_comparison.py.
What the difference actually is
Not the line count. Lines are the least durable thing in the table.
Media processing has to live somewhere else
compute-service/Neither Deno nor the Convex runtime can execute ffmpeg, so both need compute-service/. Three of its seven endpoints are ffmpeg and have no hosted-API substitute. The extra service, most of the orchestration hops, and base64 on the wire all follow from that one fact.
Processing fires for any writer
A row inserted into a Pixeltable table by anything at all gets processed, because the pipeline is the schema. On Supabase or Convex the processing lives in the ingest path, so a row written by another client, a backfill, or a psql session is not processed unless you add triggers or a scheduled action.
Retrieval knows its own model
similarity(string=q)similarity(string=q) asks the index. The other two embed the query themselves and nothing checks it came from the model that filled the column; the dimension is the only guard, and 384 equals 384.
Errors are per cell
A Pixeltable cell holds a value or its own errormsg and errortype, selectable like any other column. Elsewhere a failed step leaves a NULL and finding the affected rows is a query you write.
Lineage is in the catalog
pxt dashboardEvery computed column carries the expression that produced it. pxt dashboard draws column and table lineage locally, with no deploy and no account. Supabase Studio and the Convex dashboard both ship in this benchmark; neither knows what produced a column, because nothing recorded it.
Consequences
Even swaps from the benchmark. Check is built in, dash is partial or a script, x is not here.
built in·partial / extra work· not in this implementation·— n/a
| Feature | Pixeltable | Supabase | Convex |
|---|---|---|---|
| Media and the pipeline | |||
| ffmpeg, Whisper, CLIP run in-platform | computed columns | compute-service | compute-service |
| Processing fires for any writer | the pipeline is the schema | unless you add triggers | unless you add a scheduler |
| Add a derived column to live data | backfills in place | migration + backfill script | schema + migration action |
| Per-cell error state | errormsg, errortype | NULL plus a query you write | |
| Retrieval knows its own model | similarity(string=q) | you embed; dimension is the guard | you embed; 256-hit ceiling |
| Install and operations | |||
| Local install | one package; large Python deps | Docker, 12 containers, or hosted | npx convex dev, no account |
| Cloud account required to run locally | not for local Docker | anonymous local backend | |
| Operations | you run the process; Cloud is separate | managed | managed |
| Vendor ships a conformance checker | ruff only; weakest automated proof | deno lint + db advisors | ESLint plugin + tsc |
| Free tier | self-hosted in this benchmark | ||
| Auth, realtime, versioning | |||
| Endpoints authenticated by default | open in this repo | withSupabase({ auth: 'secret' }) | open in this repo |
| Row-level security | enabled and verified on all five tables | ||
| Realtime push to clients | the core idea | ||
| Data versioning | per-table history and revert | PITR, branching, migrations | snapshot export/import |
The same task, from the repo
Snippets are shortened from the three implementations. None are invented.
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.
class Videos(TableModel, name='videos'):video: pxt.Videotitle: pxt.Stringaudio = 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'}])
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) => {const path = `videos/${videoId}/frame_${i}.jpg`;await supabase.storage.from("frames").upload(path, decodeBase64(b64), {contentType: "image/jpeg", upsert: true,});return { video_id: videoId, frame_idx: i, embedding: embeddings[i] };}));await supabase.from("frames").insert(frameRows);
67 lines in one function, plus compute-service. A row that arrives any other way is not processed.
The same ten steps, three ways
Judgments, not measurements. Convex wins install. Pixeltable wins the media steps.
| Feature | Pixeltable | Supabase | Convex |
|---|---|---|---|
| 1. Install | Adequate One package; pulls torch, whisper, sentence-transformers | Weak Docker, 12 containers, or hosted — plus compute-service | Strong npx convex dev, no account, no Docker |
| 2. Schema | Strong 2 tables, 2 views, same file as everything else | Adequate 5 tables, 3 FKs, 2 HNSW indexes, 1 view | Adequate 5 tables, 2 vector indexes; ingest writes complete rows |
| 3. Ingest | Strong One insert | Adequate 67-line function | Adequate 61 lines plus 105 lines of mutations |
| 4. Process | Strong It is the schema | Weak Lives in the ingest path | Weak Lives in the ingest path |
| 5. Embed | Strong One line; the index knows its model | Weak Second service | Weak Second service |
| 6. Search | Strong An expression; the view supplies the title | Adequate SQL function plus join | Adequate vectorSearch plus a batched lookup |
| 7. Agent | Strong Retrieval is a column; evidence is stored with the answer | Adequate Assembled and written by the handler | Adequate Assembled and written by the handler |
| 8. Serve | Strong Declared routes next to the queries | Strong One function, or PostgREST | Weak Weak for REST, strong for reactive |
| 9. Evolve | Strong Incremental backfill; one command | Weak Migration plus backfill script | Weak Migration action; revert needs a second migration |
| 10. Inspect | Strong Per-cell errors, lineage, revert, local dashboard | Adequate PITR, branching; no per-cell errors | Adequate Snapshot export; function logs |
All three beat realtime on this laptop
Two corpus tiers: 20 videos ingested, then 100 more for 203 total. CPU, local models, not Cloud. Bold is best on that row.
Ingest
| Feature | Pixeltable | Supabase | Convex |
|---|---|---|---|
| Wall time · 20 videos | 62.3s | 45.8s | 47.6s |
| Wall time · 100 videos | 361.6s | 223.7s | 231.5s |
| Faster than realtime · 20 videos | 9.7x | 13.18x | 12.68x |
| Faster than realtime · 100 videos | 10.45x | 16.89x | 16.32x |
| Median video · 20 videos | 2.9s | 2.0s | 1.9s |
| Median video · 100 videos | 3.5s | 2.2s | 2.3s |
Search
| Feature | Pixeltable | Supabase | Convex |
|---|---|---|---|
| Frame search p50 · 23 videos | 20.9ms | 20.4ms | 13.9ms |
| Frame search p50 · 203 videos | 22.2ms | 17.3ms | 15.3ms |
| Frame search p95 · 23 videos | 22.4ms | 26.5ms | 18.1ms |
| Frame search p95 · 203 videos | 24.2ms | 21.2ms | 21.5ms |
| Transcript search p50 · 23 videos | 17.9ms | 17.0ms | 11.2ms |
| Transcript search p50 · 203 videos | 17.0ms | 16.6ms | 11.4ms |
| Transcript search p95 · 23 videos | 21.1ms | 21.9ms | 14.6ms |
| Transcript search p95 · 203 videos | 18.5ms | 22.2ms | 16.5ms |
Agent
Local Qwen2.5-1.5B on the 3-video baseline; retrieval is a fixed top-4 per index, so generation dominates.
| Feature | Pixeltable | Supabase | Convex |
|---|---|---|---|
| Agent query p50 | 206.1ms | 700.3ms | 807.8ms |
| Agent query p95 | 235.0ms | 851.1ms | 1104.3ms |
Reads
| Feature | Pixeltable | Supabase | Convex |
|---|---|---|---|
| GET /videos p50 · 23 videos | 6.1ms | 6.4ms | 1.9ms |
| GET /videos p50 · 203 videos | 5.1ms | 7.8ms | 2.4ms |
| Frame fetch p50 · 23 videos | 1.7ms | 2.8ms | 0.4ms |
| Frame fetch p50 · 203 videos | 0.7ms | 2.1ms | 0.4ms |
Under load, 8 clients
The same ten searches with eight clients in flight; measures degradation, not speed.
| Feature | Pixeltable | Supabase | Convex |
|---|---|---|---|
| Concurrent p50 · 23 videos | 173.6ms | 64.9ms | 63.1ms |
| Concurrent p50 · 203 videos | 119.5ms | 67.0ms | 65.2ms |
| Concurrent p95 · 23 videos | 264.6ms | 197.3ms | 72.9ms |
| Concurrent p95 · 203 videos | 173.7ms | 123.9ms | 72.8ms |
Large tier ingests 20 videos (603.7s of footage) into a 3-video table; xl ingests 100 more (3,778s) for a 203-video, 7,689-frame corpus. All three finished every video at both tiers; Pixeltable logged one retried attempt at xl. Supabase led ingest at both tiers and the gap widened rather than shrank: 1.36x at large, 1.62x at xl. Search separates by single-digit milliseconds, because most of every number is embedding the query, not the index. The agent runs on the 3-video baseline, where Pixeltable is 3-4x faster because one question costs the other two three compute-service round trips and costs it none. Under eight concurrent search clients that same property inverts: the in-process embedding serializes at 120-174ms while the other two hold ~65ms. One machine, one afternoon: read the gaps, not the milliseconds.
One hosted model, three ways to call it
The agent again, with local generation swapped for nvidia/nemotron-3-super-120b-a12b:free on OpenRouter, identical for all three. 12 questions, 6 workers, then every patch reverts.
| Feature | Pixeltable | Supabase | Convex |
|---|---|---|---|
| Answered of 12 | 8 | 12 | 12 |
| Wall time | 23.7s | 6.0s | 10.5s |
| p50 | 2.8s | 1.5s | 2.8s |
| p95 | 17.7s | 3.7s | 7.3s |
| Retries | scheduler-internal | 2 | 1 |
| Lines written for the swap | 7 | 36 | 37 |
The swap is the measurement: on Pixeltable it is a 7-line schema change and the rate-limit scheduler paces and retries; on the other two it is a 36-37 line helper, because pacing, Retry-After and backoff are application code. The finding is the failure mode. The free pool can return HTTP 200 carrying an upstream error body or an empty completion, which a hand-written loop can inspect and retry; a computed column evaluates the response it is given, so a malformed 200 lands as a null answer, and the four misses are exactly those calls. Free-tier saturation moves minute to minute, so the counts are a snapshot of one window, not a platform property. What does not move: who wrote the retry code, and who could see inside the response.
Adding a column to live data
Make the title semantically searchable on a populated catalog. Lines compound; these seconds do not.
| Feature | Pixeltable | Supabase | Convex |
|---|---|---|---|
| Schema change | 9.06s | 0.08s | 5.94s |
| Backfill | same step | 1.38s | 1.09s |
| Total | 9.06s | 1.45s | 7.03s |
| Lines written | 1 | 24 | 53 |
| Files touched | 1 | 2 | 2 |
- Supabase is the fastest in wall time. At two dozen rows the backfill is noise; anyone quoting these seconds as a scaling result is quoting noise.
- Reverting is not symmetric. Convex needs a second migration (17 of its 53 lines) because pushing a schema that no longer declares the field is rejected while documents still carry it.
- Convex also has a lexical searchIndex path: 1 line, 1.35s, no backfill, because it indexes a field that already exists rather than computing an embedding. It answers a different query.
- After pxt schema update, an insert against the already-registered route answers 409 until pxt service update. Reads keep working. The other two resolve the table on every request.
- Backfill time is the part that scales, and this corpus cannot show it. Pixeltable’s backfill is work proportional to the rows that changed; a backfill script is work proportional to the table.
When to use what
These are not mutually exclusive. Pixeltable as the media layer behind Supabase or Convex is a coherent architecture.
- The workload is media-heavy and the pipeline is the productPixeltable
- You want Postgres, RLS, realtime, PITR, branching, or a managed database your team already operatesSupabase
- Reactivity is the point — the UI re-renders on writeConvex
- CRUD app with auth and live subscriptionsSupabase or Convex
- You already run Supabase or Convex and need a media and retrieval layerPixeltable alongside — they are not mutually exclusive
Costs the other two pay for media
Not stack folklore. These are properties of the three implementations.
A second Python process because the runtime cannot run ffmpeg
compute-service/Deno Edge Functions and the Convex runtime have no subprocess, so ffmpeg, Whisper, and CLIP live in compute-service/. Three of its seven endpoints have no hosted-API substitute. Swap embeddings to OpenAI and the service shrinks; it does not vanish. You then operate two runtimes, most of the orchestration hops, and base64 on the wire.
Processing that lives only in the ingest path
A 67-line (Supabase) or 61-line (Convex) ingest function is the idiomatic shape and it is fast. A row that arrives from a backfill, a second client, or a psql session is not processed. Getting that behaviour back means database triggers or a scheduled action, and a webhook or a job per row. On Pixeltable the pipeline belongs to the table, so it fires for every writer.
Embedding the query with a model you hope matches the index
similarity(string=q)similarity(string=q) asks the index. The other two embed the query themselves. Nothing checks it came from the model that filled the column; the dimension is the only guard. Convex’s vectorSearch also clamps to 256 hits.
Where this is favourable to Pixeltable
Stated so you do not have to find it yourself. The sponsor’s implementation has the weakest automated proof.
The response envelope is { rows: [...] }
That is what Pixeltable’s FastAPIRouter.add_query_route emits natively. Supabase and Convex build their JSON by hand either way, so it costs them nothing, but it was not chosen neutrally.
Local models suit Pixeltable
Running everything on CPU with no API key makes the benchmark reproducible, and it also removes a hosted-API dependency Pixeltable would otherwise share. With OpenAI, all three would gain one external host; Pixeltable would still gain no orchestration.
The app is media-heavy
Video ingest, frame extraction, and transcription are exactly what computed columns are for. A CRUD application with real-time subscriptions would read very differently, and Convex in particular would look much better.
One author wrote all three
The Pixeltable version had the benefit of knowing what the contract needed. The other two follow their vendors’ documented guidance and pass their vendors’ own checkers, but they were still not written by their platforms’ experts.
The contract is REST-shaped
That is Pixeltable’s native serving surface, Supabase’s third-best (behind PostgREST and Realtime), and Convex’s worst. Convex’s http.ts is 90 lines that exist only because we asked for REST instead of using its reactive client.
compute-service is charged in full to both competitors
It is 45% of Supabase’s total and 37% of Convex’s. Roughly half of it would disappear behind a hosted embedding API; the ffmpeg half would not.
Auth, RLS, realtime, and cost are out of frame
This repo runs on a service-role key and writes no per-tenant policy. For a multi-tenant product those are decisive, and Supabase and Convex both have answers where Pixeltable, here, does not. Dollars, p99, cold starts, and team familiarity are unmeasured.
Outside the contract
- Hosted-model scheduling is exercised by a separate tier, not the contract corpus: harness/bench_hosted.py swaps all three to one OpenRouter model and fires the agent questions concurrently. The malformed-200 trade-off it surfaces is measured above and in docs/hosted.json.
- Only FrameIterator and AudioSplitter run. Five other iterators ship and go unmeasured.
- pxt dashboard lineage has no counterpart on Studio or the Convex dashboard, because the other two record nothing to draw. Those UIs are also unmeasured.
Reproduce every number: github.com/pixeltable/pixeltable-vs-supabase-vs-convex
Frequently asked questions
One file. The whole pipeline.
Declare the tables. Apply the schema. Insert a row. Serve the same file.
pip install 'pixeltable[serve]'