Why Chroma upgrades break things
Chroma has moved fast. Across versions it has changed:
- The on-disk storage format (for example, the move from DuckDB/Parquet to SQLite in 0.4, which needed a migration tool).
- The HTTP API version (v1 to v2 endpoints) and client/server compatibility.
- Configuration syntax (
hnsw:*metadata keys to configuration objects in 1.x). - Return types (
list_collections()returning names instead of objects in 0.6). - The server implementation (the core rewritten in Rust for 1.0).
Most of these come with migration notes and automatic migrations, but an unplanned upgrade in a production container can still leave an app that can't start. Treat Chroma like any other database.
Step 1: pin versions now
# requirements.txt
chromadb==<exact version>
# docker-compose.yml
image: chromadb/chroma:<exact version>
Pin both the Python package and the server image, and upgrade them together.
Step 2: read the release notes for every version you cross
Going from 0.5.x to 1.x crosses several releases. For each, look for:
| Note type | Impact |
|---|---|
| Storage migration | Automatic on first open? Irreversible? Needs a tool? |
| API changes | Client code changes (renamed parameters, return types) |
| Client/server compatibility | Must upgrade both at once? |
| Configuration changes | Collection settings syntax, env variables, data paths in the Docker image |
| Removed features | Built-in auth providers, deprecated endpoints |
Write the list of required code changes before touching production.
Step 3: back up
Stop writers, then copy the data directory:
# server in Docker
docker compose stop chroma
docker run --rm -v <project>_chroma-data:/data -v "$PWD":/backup alpine \
tar czf /backup/chroma-pre-upgrade-$(date +%F).tgz -C /data .
docker compose start chroma
# embedded
tar czf chroma-pre-upgrade-$(date +%F).tgz -C /app/chroma .
Also export counts per collection as a baseline:
import chromadb, json
client = chromadb.HttpClient(host="chroma", port=8000)
baseline = {}
for c in client.list_collections():
name = c if isinstance(c, str) else c.name
baseline[name] = client.get_collection(name).count()
json.dump(baseline, open("baseline-counts.json", "w"), indent=1)
Step 4: test the upgrade on a copy
- Restore the backup into a new directory or volume.
- Start the new server version (or open with the new package) against the copy.
- Run:
client = chromadb.HttpClient(host="localhost", port=8001) # test instance
for name, n in baseline.items():
col = client.get_collection(name)
assert col.count() == n, (name, col.count(), n)
col.query(query_embeddings=[sample_vec[name]], n_results=3)
print("ok")
- Run your application's retrieval evaluation set against the test instance and compare with the old version.
- Note how long the first open takes; automatic migrations on large data can take minutes.
If the copy fails to open or counts differ, stop and use the fallback (Step 7).
Step 5: upgrade production
| Order | Action |
|---|---|
| 1 | Announce a short write freeze; pause ingestion |
| 2 | Final backup |
| 3 | Deploy new server image |
| 4 | Wait for health (/api/v2/heartbeat on 1.x) and verify counts |
| 5 | Deploy app with the new client version and code changes |
| 6 | Run smoke queries and the evaluation set |
| 7 | Resume ingestion |
For embedded deployments, steps 3 and 5 are the same deploy.
Step 6: rollback
A storage migration may be one-way. Rolling back means:
- Restore the pre-upgrade backup to the data path.
- Deploy the old server image and old client.
- Replay any writes made after the upgrade from your ingestion log.
Keep the backup until the new version has run for at least a week.
Step 7: fallback - export and import
When an in-place migration isn't possible or fails, move the data through the API with both versions running side by side:
old = chromadb.HttpClient(host="chroma-old", port=8000) # old version server on old data
new = chromadb.HttpClient(host="chroma-new", port=8000) # new version server, empty volume
for c in old.list_collections():
name = c if isinstance(c, str) else c.name
src = old.get_collection(name)
dst = new.create_collection(name, configuration={"hnsw": {"space": SPACE[name]}})
offset = 0
while True:
b = src.get(include=["embeddings", "documents", "metadatas"], limit=1000, offset=offset)
if not b["ids"]:
break
dst.add(ids=b["ids"], embeddings=b["embeddings"], documents=b["documents"], metadatas=b["metadatas"])
offset += 1000
print(name, src.count(), dst.count())
Client versions: one Python environment can only have one chromadb version installed. Run the export with the old client version to a neutral format (JSON Lines or Parquet with ids, embeddings, documents, metadatas), then import with the new version from a separate environment. That also gives you a portable backup.
import json
with open(f"{name}.jsonl", "w") as f:
for i, e, d, m in zip(b["ids"], b["embeddings"], b["documents"], b["metadatas"]):
f.write(json.dumps({"id": i, "embedding": list(map(float, e)), "document": d, "metadata": m}) + "\n")
Checklist
| Item | Done when |
|---|---|
| Versions pinned | Package and image pinned and upgraded together |
| Release notes | Read for every crossed version; code changes listed |
| Backup | Data directory archived; baseline counts saved |
| Test | Copy opened with new version; counts and quality match |
| Rollback | Backup kept; old images available |
Prevent it next time
- Pin Chroma like a database version, never
chromadb>=…in production. - Keep a JSONL/Parquet export of every collection as a version-independent backup.
- Script collection creation (space, HNSW settings) so rebuilding on a new version is easy.
- Upgrade one minor version at a time when the release notes mention storage changes.