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.

pip install 'pixeltable[serve]'
See how it worksRun the benchmark

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.

FeaturePixeltableSupabaseConvex
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 dashboard

    Every 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

FeaturePixeltableSupabaseConvex
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.

Pixeltableapp.py
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'}])
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.

FeaturePixeltableSupabaseConvex
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

FeaturePixeltableSupabaseConvex
Wall time · 20 videos62.3s45.8s47.6s
Wall time · 100 videos361.6s223.7s231.5s
Faster than realtime · 20 videos9.7x13.18x12.68x
Faster than realtime · 100 videos10.45x16.89x16.32x
Median video · 20 videos2.9s2.0s1.9s
Median video · 100 videos3.5s2.2s2.3s

Search

FeaturePixeltableSupabaseConvex
Frame search p50 · 23 videos20.9ms20.4ms13.9ms
Frame search p50 · 203 videos22.2ms17.3ms15.3ms
Frame search p95 · 23 videos22.4ms26.5ms18.1ms
Frame search p95 · 203 videos24.2ms21.2ms21.5ms
Transcript search p50 · 23 videos17.9ms17.0ms11.2ms
Transcript search p50 · 203 videos17.0ms16.6ms11.4ms
Transcript search p95 · 23 videos21.1ms21.9ms14.6ms
Transcript search p95 · 203 videos18.5ms22.2ms16.5ms

Agent

Local Qwen2.5-1.5B on the 3-video baseline; retrieval is a fixed top-4 per index, so generation dominates.

FeaturePixeltableSupabaseConvex
Agent query p50206.1ms700.3ms807.8ms
Agent query p95235.0ms851.1ms1104.3ms

Reads

FeaturePixeltableSupabaseConvex
GET /videos p50 · 23 videos6.1ms6.4ms1.9ms
GET /videos p50 · 203 videos5.1ms7.8ms2.4ms
Frame fetch p50 · 23 videos1.7ms2.8ms0.4ms
Frame fetch p50 · 203 videos0.7ms2.1ms0.4ms

Under load, 8 clients

The same ten searches with eight clients in flight; measures degradation, not speed.

FeaturePixeltableSupabaseConvex
Concurrent p50 · 23 videos173.6ms64.9ms63.1ms
Concurrent p50 · 203 videos119.5ms67.0ms65.2ms
Concurrent p95 · 23 videos264.6ms197.3ms72.9ms
Concurrent p95 · 203 videos173.7ms123.9ms72.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.

FeaturePixeltableSupabaseConvex
Answered of 1281212
Wall time23.7s6.0s10.5s
p502.8s1.5s2.8s
p9517.7s3.7s7.3s
Retriesscheduler-internal21
Lines written for the swap73637

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.

FeaturePixeltableSupabaseConvex
Schema change9.06s0.08s5.94s
Backfillsame step1.38s1.09s
Total9.06s1.45s7.03s
Lines written12453
Files touched122
  • 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]'
See how it works