Backfill Embeddings for 1M Rows Without Killing Postgres
For: A backend engineer at a 20–80-person B2B SaaS company who just added pgvector to their Postgres instance and needs to generate embeddings for an existing products or documents table that already has 800K–2M rows — without locking the table, spiking OpenAI costs, or explaining a weekend outage to their CTO
Treat the backfill as a resumable work queue, not a batch job. Add an embedding column plus a claimed_at lease column, have workers atomically claim small batches with FOR UPDATE SKIP LOCKED, call the embedding API, and write results back one row at a time. This gives you crash-safety, horizontal parallelism, and short-lived locks — which is what keeps a live Postgres instance healthy while you churn through a million rows.
The rest of this post is the runnable version of that idea. It assumes you already added pgvector and are staring at a products or documents table with 800K–2M rows and live write traffic.
Why the obvious approaches break
Before the tutorial, the three failure modes worth naming:
- The
UPDATE ... FROM (SELECT ...)mega-query. Locks huge row ranges, blocks writers, and if it dies at 78% you have no idea which rows finished. - The Python script with
SELECT ... WHERE embedding IS NULL LIMIT 1000in a loop. Fine until you want to run two workers in parallel — now they fight over the same rows and you double-bill OpenAI. - The Airflow/Celery fan-out. Works, but if the job dies mid-flight, resuming is manual archaeology. And most teams don't need that much machinery.
The lease pattern below solves all three with about 40 lines of SQL and 60 lines of Python.
Prerequisites
- Postgres 12+ with the
vectorextension installed (CREATE EXTENSION vector;) - A table with a stable primary key — we'll use
documents(id BIGINT PRIMARY KEY, content TEXT) - An OpenAI API key (or any embedding provider — the pattern is identical)
- Python 3.10+,
psycopg[binary],openai,tenacity - Ability to run
ALTER TABLEduring a low-traffic window (only for adding nullable columns — this is fast)
Step 1: Add the embedding and lease columns
Adding nullable columns in Postgres 11+ is metadata-only — no table rewrite, no long lock.
ALTER TABLE documents
ADD COLUMN embedding vector(1536),
ADD COLUMN embedding_model text,
ADD COLUMN embedded_at timestamptz,
ADD COLUMN claimed_at timestamptz;
CREATE INDEX CONCURRENTLY documents_embed_todo_idx
ON documents (id)
WHERE embedding IS NULL;Two things worth noting. First, the partial index on WHERE embedding IS NULL is what makes claiming rows fast — it shrinks as the backfill progresses. Second, we're not building the HNSW vector index yet. Do that after the backfill; building it now means every insert pays the cost twice.
Expected output:
ALTER TABLE
CREATE INDEXStep 2: Write the claim query
This is the heart of the pattern. One SQL statement claims a batch and marks it — atomically, without holding locks after commit.
WITH claimed AS (
SELECT id
FROM documents
WHERE embedding IS NULL
AND (claimed_at IS NULL OR claimed_at < now() - interval '10 minutes')
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED
)
UPDATE documents d
SET claimed_at = now()
FROM claimed
WHERE d.id = claimed.id
RETURNING d.id, d.content;What each piece does:
FOR UPDATE SKIP LOCKED— if another worker already has this row, skip it instead of blocking. This is what makes N workers safe.claimed_at < now() - interval '10 minutes'— if a worker crashed mid-batch, its rows become claimable again after 10 minutes. Adjust based on your API timeout.LIMIT 100— small enough that a crash loses little work, large enough that OpenAI's batch endpoint is efficient.ORDER BY id— deterministic, uses the primary key, keeps the query planner happy.
Step 3: The worker loop
import os, time, psycopg
from openai import OpenAI
from tenacity import retry, wait_exponential, stop_after_attempt
client = OpenAI()
MODEL = "text-embedding-3-small"
BATCH = 100
CLAIM_SQL = """
WITH claimed AS (
SELECT id FROM documents
WHERE embedding IS NULL
AND (claimed_at IS NULL OR claimed_at < now() - interval '10 minutes')
ORDER BY id LIMIT %s
FOR UPDATE SKIP LOCKED
)
UPDATE documents d SET claimed_at = now()
FROM claimed WHERE d.id = claimed.id
RETURNING d.id, d.content;
"""
WRITE_SQL = """
UPDATE documents
SET embedding = %s::vector,
embedding_model = %s,
embedded_at = now(),
claimed_at = NULL
WHERE id = %s;
"""
@retry(wait=wait_exponential(min=1, max=60), stop=stop_after_attempt(6))
def embed(texts):
resp = client.embeddings.create(model=MODEL, input=texts)
return [d.embedding for d in resp.data]
def run():
conn = psycopg.connect(os.environ["DATABASE_URL"], autocommit=False)
while True:
with conn.cursor() as cur:
cur.execute(CLAIM_SQL, (BATCH,))
rows = cur.fetchall()
conn.commit()
if not rows:
print("nothing to do, sleeping")
time.sleep(30)
continue
ids, texts = zip(*[(r[0], (r[1] or "")[:8000]) for r in rows])
vectors = embed(list(texts))
with conn.cursor() as cur:
for id_, vec in zip(ids, vectors):
cur.execute(WRITE_SQL, (vec, MODEL, id_))
conn.commit()
print(f"embedded {len(ids)} rows")
if __name__ == "__main__":
run()Notes on what's deliberately not in there:
- No global transaction wrapping the whole batch. The claim commits, then the writes commit. If the API call fails, the lease expires and the rows come back into the pool.
- No threading. Run multiple processes instead —
python worker.pyin three terminals or three Kubernetes pods. Postgres handles the coordination. - Truncating
contentto 8000 chars is a crude token guard. Usetiktokenif your content varies wildly.
Step 4: Run it and watch progress
Start one worker first to confirm the pipeline works:
$ python worker.py
embedded 100 rows
embedded 100 rows
embedded 100 rowsIn a separate psql session, watch progress:
SELECT
count(*) FILTER (WHERE embedding IS NOT NULL) AS done,
count(*) FILTER (WHERE embedding IS NULL) AS todo,
count(*) FILTER (WHERE claimed_at IS NOT NULL AND embedding IS NULL) AS in_flight
FROM documents;Expected output:
done | todo | in_flight
-------+--------+-----------
12400 | 987600 | 200Once one worker is stable, scale out. Two to four workers is usually the sweet spot before you hit either OpenAI rate limits or your Postgres connection pool.
Step 5: Rate-limit and cost controls
OpenAI's text-embedding-3-small has generous limits, but at four workers × 100 rows/sec you'll notice. Two practical controls:
- Token-bucket in the worker. Add a simple sleep if you exceed N requests/minute. The
tenacityexponential backoff already handles 429s, but preemptive pacing is cheaper than retries. - A daily row cap. If finance wants to spread cost across the month, add
AND id NOT IN (SELECT id FROM documents WHERE embedded_at > now() - interval '24 hours' LIMIT 200000)to the claim query. Ugly but effective.
Also: pick the smallest model that meets your recall target. text-embedding-3-small at 1536 dims is fine for most product-search and doc-retrieval use cases. Only jump to -large if you've measured a real recall gap.
Step 6: Handle new rows going forward
Once the backfill catches up, you need to keep new inserts embedded. Two options:
- Let the same workers keep running. The claim query already finds any row where
embedding IS NULL. New inserts just get picked up on the next poll. Cheapest option. - Trigger +
NOTIFY. AnAFTER INSERTtrigger firespg_notify('embed_todo', NEW.id::text), and workersLISTENfor lower latency. Worth it only if you need sub-minute freshness.
For most B2B SaaS use cases — semantic search over a product catalog, doc retrieval for a support bot — option 1 with a 30-second poll is genuinely fine.
Step 7: Build the vector index — after backfill
Now build the index. Do this after the backfill completes, and do it concurrently.
CREATE INDEX CONCURRENTLY documents_embedding_hnsw
ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);HNSW build on a million rows is not free — it's CPU-heavy and takes a while. But CONCURRENTLY means no write lock. Run it during a quieter period and monitor with pg_stat_progress_create_index.
Once built, drop the partial todo index — it's no longer useful:
DROP INDEX documents_embed_todo_idx;Step 8: Verify and clean up
-- sanity check: no orphans
SELECT count(*) FROM documents WHERE embedding IS NULL;
-- sanity check: no stuck leases
SELECT count(*) FROM documents
WHERE claimed_at IS NOT NULL AND embedding IS NULL;
-- optional: drop the lease column if you don't need re-embedding later
ALTER TABLE documents DROP COLUMN claimed_at;Keep embedding_model and embedded_at. You'll thank yourself when you switch models next year and need to know which rows to re-embed.
Common errors and how to fix them
"could not obtain lock on row" or workers stalling
You forgot SKIP LOCKED, or you're running the claim inside a longer transaction. The claim query must commit immediately. Don't wrap it with the embedding API call in a single transaction.
OpenAI 429 rate limit errors even with retries
Reduce worker count first, then batch size. Also check whether you're hitting the tokens-per-minute limit rather than requests-per-minute — long documents blow through TPM fast. Truncate more aggressively or chunk longer content.
Rows keep getting re-claimed by the same worker
Your claimed_at lease is shorter than your actual embedding call. Bump the interval in the claim query from 10 minutes to 30, or make the API call timeout shorter than the lease.
Postgres CPU spiking during backfill
Usually the partial index isn't being used. Run EXPLAIN on the claim query — if you see a Seq Scan, run ANALYZE documents. If the planner still ignores it, force it by narrowing the WHERE clause with a range: AND id BETWEEN %s AND %s, sharded per worker.
Dimension mismatch on insert
ERROR: expected 1536 dimensions, not 3072. You changed models mid-backfill. The vector(1536) column type is fixed. Either recreate the column at the new dimension or stick with one model per column.
The embedding IS NULL count isn't decreasing
Check that the write path is actually committing. A common bug: workers claim rows, embed them, but the UPDATE loop hits an exception on one row and rolls back all of them. Commit per-row or wrap each write in its own savepoint.
What this pattern is bad at
Honest tradeoffs:
- Small tables. If you have 20K rows, this is overkill. Just run a single script with a
LIMITloop. - Very high freshness requirements. Poll-based workers have 30-second latency. If you need embeddings within a second of insert, use
LISTEN/NOTIFYor a proper queue (SQS, Redis Streams). - Multi-tenant cost attribution. The pattern doesn't tell you which tenant's rows cost how much. Add a
tenant_idto your logging if finance cares. - Very large documents. If your
contentfield is a 200-page PDF, embedding the whole thing is the wrong shape. Chunk first, embed chunks, store chunks in a separate table.
For teams working through the messier version of this — legacy schemas, mixed content types, or an existing production system where you can't just add columns freely — the same lease pattern still works, but the migration story around it takes more care. That's the kind of thing our team walks through as part of legacy modernization engagements, and we've applied variants of this queue pattern in production systems from accounting SaaS to logistics marketplaces.
Frequently Asked Questions
How long does it take to backfill embeddings for 1 million rows?
It depends almost entirely on your embedding provider's rate limits, not on Postgres. With text-embedding-3-small and 2–4 parallel workers, most teams finish a million rows well within a day. Postgres itself is nowhere near the bottleneck at this scale — the API is.
Should I use pgvector or a dedicated vector database like Pinecone or Weaviate?
If your data already lives in Postgres and your query patterns join vectors with relational filters (tenant_id, category, date ranges), stay in Postgres with pgvector. You avoid a second system and get transactional consistency for free. Move to a dedicated vector DB only when you cross tens of millions of vectors or need specialized index types pgvector doesn't offer yet.
Can I run the backfill safely while my application is serving live traffic?
Yes — that's the entire point of this pattern. Each UPDATE touches one row and commits immediately, so writers are never blocked for more than milliseconds. The main risk is CPU load from the embedding column updates and any concurrent index builds; monitor pg_stat_activity and cap worker count based on what your instance can absorb.
What happens if I need to re-embed everything with a new model later?
Add a new column (embedding_v2 vector(...)) and repeat the same backfill pattern, keyed on WHERE embedding_v2 IS NULL. Keep the old column live until the new one is fully populated and validated, then swap application reads over. The embedding_model column pays off here — you always know which rows are on which version.
How do I estimate the cost of embedding my entire table?
Sample 1000 representative rows, count their tokens with tiktoken, multiply out to your row count, then apply your provider's per-token price. For a more thorough assessment tailored to your data shape and infrastructure, contact CodeNicely for a personalized assessment.
Found this useful? CodeNicely publishes engineering and product playbooks weekly. Browse the archive or tell us what you're building.
_1751731246795-BygAaJJK.png)