Home / AI & GenAI / Vector Databases

AI & GenAITroubleshootingIntermediate

Chroma \"database is locked\" and missing data with several workers: why PersistentClient isn't multi-process safe and how to fix it

A FastAPI app with four Gunicorn workers, or an ingestion job running beside the API, all open the same Chroma directory. Writes fail with "database is locked", some workers don't see new data, and the index occasionally corrupts. Find the processes sharing the directory and move to a single owner.

Deepak Bhardwaj5 min read

Applies toChroma 0.4-1.x PersistentClient (embedded mode) in Python apps (FastAPI, Flask, Celery, Streamlit, notebooks), on local disk, Docker volumes or network shares

Symptoms

  • sqlite3.OperationalError: database is locked during add/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:

  1. Stop every process that uses the directory.
  2. Copy the directory aside (keep the broken one for analysis).
  3. Try opening it with a single client and run count() and a sample query per collection.
  4. 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 locked errors 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 HttpClient against a local Chroma container from the first prototype.
  • Ban PersistentClient in 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.