Symptoms
sqlite3.OperationalError: database is lockedduringadd/upsert.- Documents ingested by a background job don't appear in API queries until the API restarts.
- Different API workers return different results for the same query.
- After a crash, Chroma errors on start or queries fail with index errors.
- Problems only appear in production (multiple workers), never on a laptop (one process).
Why it happens
PersistentClient is an embedded database: each process that creates one opens the SQLite file and the vector index files in the directory and keeps index state in its own memory. Chroma doesn't coordinate several processes writing the same directory:
| Effect | Reason |
|---|---|
database is locked | SQLite allows one writer at a time; concurrent writers time out waiting |
| Stale data in other workers | Each process holds its own in-memory index; it doesn't reload when another process writes |
| Corruption risk | Index files written by more than one process |
Chroma documents the embedded client as intended for a single process. The fix is architectural, not a timeout setting.
Step 1: find who opens the directory
On Linux:
lsof +D /app/chroma 2>/dev/null | awk '{print $1, $2}' | sort -u
fuser -v /app/chroma/chroma.sqlite3
Typical culprits:
| Process | Why it opens Chroma |
|---|---|
Gunicorn/Uvicorn workers (--workers 4) | Each worker imports the app and creates a client |
| Celery or RQ workers | Ingestion tasks create their own client |
| Cron/ingestion script | Writes while the API runs |
| Streamlit/Jupyter | Reruns create new clients in the same process or kernel |
| Several pods on a shared volume (Azure Files, EFS, NFS) | Each pod is a process; network file systems also handle SQLite locking poorly |
Step 2: the fix - one owner, everyone else over HTTP
Run Chroma as a server and connect all processes with HttpClient:
docker run -d --name chroma -p 127.0.0.1:8000:8000 -v chroma-data:/data chromadb/chroma:<pinned-version>
import chromadb
client = chromadb.HttpClient(host="localhost", port=8000)
The server is the only process touching the files. API workers, Celery workers and scripts all become clients. Migrate the existing data by reading from the old directory with everything else stopped and writing to the server (see the Chroma server deployment post for a copy script).
Step 3: if you must stay embedded
Sometimes a small internal tool really should stay single-process. Then make it actually single-process:
| Situation | Change |
|---|---|
| Gunicorn with several workers | --workers 1 with threads (--threads 8), or async single worker |
| Ingestion separate from API | Run ingestion inside the API process (background task with a lock), or pause the API during ingestion |
| Streamlit | Create the client once with @st.cache_resource |
| Multiple pods | Run one replica only; use a ReadWriteOnce volume so a second pod can't mount it |
Create one client per process and reuse it:
from functools import lru_cache
import chromadb
@lru_cache(maxsize=1)
def get_client():
return chromadb.PersistentClient(path="/app/chroma")
Within one process, serialise writes with a lock if several threads write:
import threading
_write_lock = threading.Lock()
def upsert_chunks(col, **kw):
with _write_lock:
col.upsert(**kw)
Step 4: don't put the directory on a network share
SQLite on SMB/NFS (Azure Files, EFS, NAS) has unreliable locking and poor latency. For embedded Chroma, use a local disk or a block volume (Azure Disk, EBS) attached to one pod/VM. For a server, also prefer block storage.
Step 5: recover after corruption
If Chroma won't open the directory or queries fail after a multi-writer incident:
- Stop every process that uses the directory.
- Copy the directory aside (keep the broken one for analysis).
- Try opening it with a single client and run
count()and a sample query per collection. - If collections are damaged, restore from the last backup or rebuild from source documents.
client = chromadb.PersistentClient(path="/restore/chroma")
for c in client.list_collections():
name = c if isinstance(c, str) else c.name
col = client.get_collection(name)
print(name, col.count())
col.query(query_embeddings=[[0.0] * DIM], n_results=1) # use your collection's dimension
Rebuilding from source is often fastest for RAG data, as long as ingestion is scripted and idempotent.
Step 6: verify the fix
- Run the API with its real worker count and the ingestion job at the same time.
- Ingest a test document, then query it from several workers (repeat the request until different workers answer).
- No
database is lockederrors in logs over a full ingestion cycle.
Decision table
| Setup | Recommendation |
|---|---|
| Notebook, single script | PersistentClient is fine |
| Web app with several workers | Chroma server + HttpClient |
| API + separate ingestion | Chroma server + HttpClient |
| Several pods | Chroma server; never a shared directory |
| Tiny internal tool | Single process, one client, write lock |
Prevent it next time
- Use
HttpClientagainst a local Chroma container from the first prototype. - Ban
PersistentClientin code that runs under Gunicorn, Celery or multiple replicas (a simple code review rule or lint). - Keep Chroma data on block storage, never on a network share.
- Script ingestion so a rebuild is always possible.