Limited Time Offer: Get 50% OFF Starter & Basic Yearly Plans 🎉

Build a Video Transcription Workflow with Supabase and Postgres

Oct 13, 2026

Why transcription keeps escaping the all-in-one editor

For years the fastest way to caption a video was to click a button inside the same application you edited in. That convenience carried a structural cost. Your transcript lived inside a project file, bound to a seat licence, shaped by whatever export formats the vendor happened to support. The moment you wanted to search across five years of interviews, push captions into a localization pipeline, or let a downstream model read your footage descriptions, you hit a wall.

That wall is the reason more teams now treat transcription as a data problem rather than an editing feature. The work splits into three parts that can evolve independently: storage for the media, a relational store for the text and its metadata, and a speech model that converts audio into words. Postgres and Supabase fit the middle layer unusually well, because the middle layer is exactly where search, permissions, and workflow state collide.

A useful way to frame the decision: an editor is optimized for one human working on one timeline, while a database is optimized for many processes reading and writing the same structured facts. Transcription output is structured facts — segments, speakers, timings, confidence scores, language tags, revision history. Once you accept that framing, the architecture questions get much sharper than "which editor has the better caption button."

The reference architecture at a glance

The stack that most teams converge on has four layers, and only one of them is a database.

  • Object storage holds the raw media, extracted audio, generated thumbnails, and any large exports. Files stay in buckets; the database stores pointers and checksums.
  • Postgres holds transcripts, segments, speaker labels, job state, model configuration, and access rules. It is the source of truth for everything except the bytes themselves.
  • A speech-to-text service does the acoustic work. It can be a hosted API, a self-hosted model, or a mix of both depending on language coverage and privacy constraints.
  • An orchestration layer — edge functions, a small worker service, or a serverless queue consumer — moves data between the other three and enforces retry logic.

The important design property is that each layer can be replaced without rewriting the others. If a new speech model beats your current one on Japanese diarization, you change one adapter. If object storage costs spike, you move buckets. The database schema, which is the expensive thing to change, stays stable because it describes the shape of transcription data rather than any particular vendor's output format.

This is also where the cost story becomes legible. Subscription suites bundle storage, compute, and seats into one line item, which makes it impossible to tell whether you are paying for transcription, version history, or simply the privilege of logging in. Separating the layers turns an opaque bill into three measurable ones.

Designing a schema for transcription assets

A schema that survives contact with real footage starts with a small number of well-normalized tables and resists the temptation to dump an entire JSON response into a single column.

Core tables

create table assets (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null references auth.users(id),
  storage_path text not null,
  duration_seconds numeric,
  language text,
  created_at timestamptz default now()
);

create table transcripts (
  id uuid primary key default gen_random_uuid(),
  asset_id uuid not null references assets(id) on delete cascade,
  revision int not null default 1,
  model_name text not null,
  is_current boolean not null default true,
  full_text text not null,
  search tsvector generated always as (to_tsvector('simple', coalesce(full_text,''))) stored,
  created_at timestamptz default now(),
  unique (asset_id, revision)
);

create table segments (
  id bigserial primary key,
  transcript_id uuid not null references transcripts(id) on delete cascade,
  start_ms int not null,
  end_ms int not null,
  speaker text,
  text text not null,
  confidence numeric
);

The assets table deliberately does not know anything about transcription. It describes media. A single asset can accumulate many transcripts across revisions, languages, or model vendors — which is precisely the flexibility a project-file approach cannot offer.

Segments, not one giant blob

Storing the whole transcript as a single text field is fine for display and terrible for everything else. Segment rows give you three capabilities that pay for themselves immediately: precise player synchronization, per-speaker analytics, and the ability to re-run a model on a single noisy region without reprocessing an hour of audio.

Keep both. The full_text column exists for search ranking and fast reads, while segments carries the structure. A common mistake is to store only segments and rebuild the text on every query, which turns a simple title lookup into an aggregation over thousands of rows.

Versioning and provenance

Always store which model produced a transcript, when, and with what settings. Model quality changes between releases, and a caption that looked correct six months ago may have been generated by a version with known diarization bugs. A model_name column plus an immutable revision number lets you compare outputs side by side and roll back a bad batch without deleting history.

The is_current flag is a cheap alternative to a full temporal table. When a new revision lands, a single transaction flips the old flag and inserts the new row. Readers query where is_current and never see inconsistent state.

Wiring an external speech-to-text service into Supabase

The integration pattern matters more than the provider. Three concerns dominate: getting audio to the model safely, getting results back reliably, and surviving the inevitable duplicate delivery.

Signed uploads instead of proxy uploads

Never route large media files through your application server just to hand them to a speech API. Generate a signed upload URL, let the client or worker write directly to object storage, then insert a row that records the path, checksum, and expected duration. The database learns about the file only after it exists, which keeps transactions short and keeps your function execution time low.

Once the row exists, a trigger or an edge function enqueues a transcription job. The trigger approach is elegant because it means no code path can create an asset and forget to schedule its processing.

Webhooks and edge functions

If your speech provider supports webhooks, prefer them over polling. A webhook arrives with a job identifier, and your handler does three things: validate the signature, look up the job row, and either store the result or record the failure. Polling works but wastes function invocations and delays completion for short clips.

When the provider only supports polling, wrap the poll in a scheduled function with a short lease. Store last_polled_at on the job row so overlapping schedules do not hammer the API for the same job.

Idempotency and retries

The single most common production bug in this stack is a duplicated transcript caused by a retried webhook. Solve it structurally: give every job a unique provider-side identifier and add a unique constraint on (provider, provider_job_id) in the transcripts table. The second insert fails cleanly, and your handler can treat the conflict as success.

Retries should be bounded and observable. Track attempt_count and last_error on the job row, and move anything past a threshold into a dead-letter state rather than retrying forever. A job stuck in retry loops is invisible until someone asks why a specific video has no captions.

Search that actually finds the right clip

Once transcripts live in a database, search becomes the feature that justifies the whole migration. You rarely need one search mode; you need three layered together.

A generated tsvector column with a GIN index gives you fast keyword matching with stemming and phrase support. For mixed-language libraries, choose your text search configuration deliberately. The simple configuration avoids incorrect stemming in languages it does not understand, while dedicated configurations give better recall for English, Spanish, French, German, Italian, Portuguese, or Polish content.

create index transcripts_search_idx on transcripts using gin (search);

select asset_id, ts_rank(search, websearch_to_tsquery('simple', 'budget forecast')) as rank
from transcripts
where is_current and search @@ websearch_to_tsquery('simple', 'budget forecast')
order by rank desc
limit 20;

websearch_to_tsquery is the right default for user-facing search because it accepts quoted phrases and minus operators without throwing syntax errors on stray punctuation.

Semantic search with embeddings

Keyword search fails on paraphrases. A viewer searching for "the part where they argue about shipping delays" will not match a transcript that says "logistics slipped again." Storing a vector embedding per segment — or per transcript chunk — and querying with cosine distance closes that gap.

Keep embeddings in a separate table keyed to the segment, not in the transcript row. Embeddings are large, they change when you upgrade an embedding model, and mixing them into the hot text table hurts every query that does not need them.

Hybrid ranking

In practice, hybrid retrieval outperforms either method alone. Run both queries, normalize the scores, and combine them with a weighted sum, then apply business filters such as recency, speaker, or project. A practical starting ratio is sixty percent semantic, forty percent keyword, adjusted after you look at real failed searches.

Using Postgres as the job queue

You do not need a separate message broker for a transcription pipeline. A well-designed job table handles thousands of jobs per hour and gives you something a broker usually does not: queryable history.

The skip-locked pattern

with next_job as (
  select id from jobs
  where status = 'queued' and run_after <= now()
  order by priority desc, created_at
  limit 1
  for update skip locked
)
update jobs set status = 'running', started_at = now(), attempt_count = attempt_count + 1
from next_job where jobs.id = next_job.id
returning jobs.*;

FOR UPDATE SKIP LOCKED lets multiple workers pull jobs concurrently without blocking each other or double-claiming work. It is the single most important line in this section.

Priority, backoff, and visibility

Give jobs a priority integer, a run_after timestamp for backoff, and a lease_expires_at column. If a worker dies mid-job, a reaper query can return expired leases to the queue. Without a lease, crashed jobs sit in running forever and nobody notices.

Separate short clips from long recordings. A four-hour webinar should not block a thirty-second social cut. Either use two queues or weight priority by expected duration.

Configuration tables instead of hardcoded settings

Store model names, language hints, diarization flags, and prompt templates in a table rather than in environment variables. Configuration changes then become auditable rows, and you can roll out a new model to a percentage of traffic by filtering on asset tags. This is also the cleanest place to record which settings produced which output, which makes debugging quality regressions far easier.

Permissions, residency, and retention

Transcription data is often more sensitive than the video itself. An on-camera conversation that nobody scrubs through becomes fully searchable text the moment it is transcribed, which changes the risk profile completely.

Row-level security as the default

Define policies on every table that holds text. A typical rule grants access when the caller owns the asset, or belongs to a team that has been granted access to the project. Keep policies simple and indexed; complex policy joins on hot tables are a common cause of slow queries.

Test policies with a dedicated set of integration tests that assume the role of a non-owner. Permissions bugs are silent, and a leaked transcript is not something you can quietly fix later.

Residency and regional storage

If your content includes personal data, the region where audio and text are stored may matter more than any feature comparison. Choose a database region deliberately, keep derived artifacts in the same jurisdiction as the source media, and document which speech providers receive data and under what terms. This is a design decision, not a procurement afterthought.

Retention and deletion

Deletion must cascade. When an asset is removed, the transcript, segments, embeddings, and derived exports all go with it, and object storage objects are removed by a background task that reconciles the bucket against the database. Keep a deletion log with timestamps so you can answer the question "was this really removed?" without guessing.

Cost and performance decisions

Three levers control most of the bill.

  1. Storage tiering. Move cold audio to cheaper storage after processing. Transcripts are small; media is not.
  2. Index discipline. Every GIN index on a large text column costs write throughput. Index what you search, not everything you store.
  3. Compute locality. Run workers close to the database region to avoid cross-region latency on every job claim.

Partition the segments table by month once it passes tens of millions of rows. Before that, partitioning adds complexity without much benefit. Watch query plans on the transcript list endpoint, since that is where N+1 patterns and missing composite indexes usually show up first.

One more decision deserves attention: when to keep transcription in-house versus when to outsource it. Keep it in-house when the schema, search relevance, and permission model are your differentiators. Outsource the acoustic model itself when a specialist clearly wins on accuracy for your languages, and treat the provider as a swappable adapter behind your own interface.

Mistakes that cost teams weeks

  • Storing only the final text. Without segments you lose timing, speaker data, and any hope of partial reprocessing.
  • Assuming one language per asset. Code-switching and multi-speaker recordings break naive language assumptions; store a primary language and allow per-segment overrides.
  • No idempotency key. Duplicate webhooks are not an edge case, they are a Tuesday.
  • Unbounded retries. A job that fails a hundred times is a bug report nobody filed.
  • Skipping the audit trail. If you cannot say which model produced a transcript, you cannot debug quality complaints.
  • Ignoring search recall. Teams migrate for search and then ship an exact-match query builder.

Each of these is cheap to prevent at schema design time and expensive to retrofit once you have a library of thousands of assets.

FAQ

Do I need a separate message broker for transcription jobs?
For most workloads, no. A job table with FOR UPDATE SKIP LOCKED handles concurrency well and keeps job history queryable. Move to a dedicated broker only when you need fan-out to many consumers or throughput beyond what your database comfortably sustains.

How large should transcript chunks be for embeddings?
Roughly one to three sentences, aligned to segment boundaries. Chunks that split mid-sentence produce noisy matches, and chunks that span several minutes dilute the signal until similarity search stops being useful.

Should I store vectors in Postgres or a dedicated vector database?
Start in Postgres. Keeping vectors next to your permission rules and metadata avoids a second source of truth and lets filters run in the same query. Revisit that choice when your embedding table outgrows your working set by a wide margin.

What is the minimum viable schema?
Four tables: assets, jobs, transcripts, and segments. Everything else — speakers, projects, embeddings, exports — can be added incrementally without rewriting what already works.

How do I handle corrections made by humans?
Never overwrite machine output. Insert a new revision marked as human-edited, flip is_current, and keep the original for comparison and for measuring model accuracy over time.

How do I know the migration was worth it?
Measure three things before and after: time to find a specific spoken phrase across the library, cost per hour of processed media, and the number of integration points that break when you change tools. If all three improve, the architecture is doing its job.

Alexander

Alexander