cd /news/ai-infrastructure/migrate-pinecone-to-qdrant-complete-… · home topics ai-infrastructure article
[ARTICLE · art-114446] src=pub.towardsai.net ↗ pub= topic=ai-infrastructure verified=true sentiment=· neutral

Migrate Pinecone to Qdrant: Complete Migration Guide | Zero Heart Burns

A new migration guide from Qdrant outlines how to move from Pinecone to Qdrant, citing Pinecone's lack of an export API, 40KB metadata limit, and 100,000 namespace cap as key pain points. The guide notes that Pinecone is SaaS-only with no self-hosting option, while Qdrant can be run locally via Docker, and warns that Pinecone rescales cosine similarity to [0, 1] whereas Qdrant returns [-1, 1].

read18 min views1 publishedAug 28, 2026

Okay so… you opened a bill from Pinecone and did a double-take. Or you tried to self-host it and discovered that’s just not a thing Pinecone lets you do. Or maybe you hit the 40KB metadata limit mid-sprint and your whole architecture needed a rethink. Whatever the reason might be you’re here, you want to move your vectors, and you want to do it without a disaster.

I’ve been through this migration. Here’s everything I wish someone had told me before I started.

Here’s the thing about Pinecone… it’s genuinely easy to get started with. The docs are everywhere in AI tutorials, the API is clean, and you can have vectors in the cloud in like 20 minutes. That’s why so many teams start there. But then production hits…

The cost curve surprises you. Pinecone’s serverless model charges per read unit, so every query costs money. At moderate QPS (say 500 queries per second), that meter is spinning fast. One team I talked to went from a comfortable $200/month in early testing to over $2k/month once their app got traction. The data doesn’t really get bigger, but the bills do.

Vendor lock-in is real. Here’s a thing that quietly terrifies me: Pinecone has no export API. Like, none. You cannot say “give me all my vectors as a file.” If you want your data back, you have to iterate through IDs and fetch them back one batch at a time, and that only works on serverless indexes. Pod-based indexes? No list API at all. Pretty wild right?

Namespace limits sneak up on you. Pinecone namespaces are a nice partitioning primitive but you’re capped at 100,000 namespaces on Standard. If you’re building a multi-tenant SaaS where each user has their own vector space… yeah, that can get uncomfortable fast.

Metadata is flat and limited. 40KB per record, no nested JSON, no geo-coordinates, no full-text search on metadata fields. Need to store a document’s full text alongside its embedding? You’ll be doing a lot of creative truncation.

No self-hosting, ever. This one is fundamental. Pinecone is SaaS-only. There is no Docker image, no Kubernetes operator, no “run it on your own machine” option. For teams with data residency requirements, regulated industries, or just a preference for not being dependent on external infrastructure… this is a hard blocker.

The lightbulb moment for most developers is when they run Qdrant locally with docker run -p 6333:6333 qdrant/qdrant and realize it's the same thing - same API, same capabilities, same performance - but it's just... there, on your machine, completely under your control.

Here’s the shape of this migration so you know what you’re getting into:

Nothing here is magic. It’s just a bit of Python and some patience.

Before touching any code, let’s map the concepts. This is reference material to skim it now, come back when something breaks.

The namespace decision is actually important. You have two options:

For most Pinecone migrations I’d go with option B (payload field), because collections have a recommended cap around 1,000 in Qdrant, and many Pinecone users have way more namespaces than that.

One gotcha here: Pinecone rescales cosine similarity to [0, 1]. Qdrant returns [-1, 1]. The rankings are identical, but any code that thresholds on raw score values will behave differently. Keep this in mind when you port your search logic.

The filter mapping rule of thumb: $and maps to must, $or maps to should, $ne maps to must_not. Numeric ranges map directly. $in maps to MatchAny. Honestly pretty intuitive once you see it side by side.

Pinecone hybrid search uses a single alpha parameter (0 = pure sparse, 1 = pure dense, linear blend). Qdrant uses explicit prefetch legs and rank fusion - either RRF (Reciprocal Rank Fusion) or DBSF. More code to write, but dramatically more control over how the fusion behaves. You can weight each leg independently, which ends up being pretty important for production tuning.

Here’s the honest truth: Pinecone has no export feature. No “Download All Data” button, no export endpoint, no snapshot you can take away. The only way out is to iterate through vector IDs and fetch them back in batches.

The approach:

One more thing before the code: this only works on serverless indexes. Pod-based indexes have no list API at all. If you're on a pod-based index... the only real option is to re-embed from your original document source, because there's no supported way to bulk-export from a pod index. Yikes.

Rate limits to know: list is 200 req/s, fetch is 100 req/s. With 1,000 IDs per fetch call, that's theoretically 100,000 vectors per second. In practice, you'll be slower because of network and parsing overhead - but you won't hit limits if you stay around 10 concurrent fetch calls.

Here’s a complete, resumable Pinecone exporter:

#!/usr/bin/env python3# VIBE CODED for smooth and quick setups"""pinecone_dumper.pyExports all vectors from a Pinecone serverless index to a JSONL file.Resumable: if interrupted, just rerun - it will pick up from where it left off.Requirements:    pip install pinecone>=3.0.0 tqdmUsage:    python pinecone_dumper.py \        --api-key pcsk_XXX \        --index-name my-index \        --output vectors.jsonl"""import argparseimport jsonimport osimport timeimport mathimport picklefrom pathlib import Pathfrom pinecone import Pineconefrom tqdm import tqdmLIST_BATCH_SIZE  = 100     # Pinecone max IDs per list pageFETCH_BATCH_SIZE = 1000    # Pinecone max IDs per fetch callFETCH_RATE_SLEEP = 0.011   # ~90 req/s, safely under 100 req/s limitdef load_checkpoint(ckpt_path: str) -> dict:    if Path(ckpt_path).exists():        with open(ckpt_path, "rb") as f:            return pickle.load(f)    return {}def save_checkpoint(ckpt_path: str, data: dict):    tmp = ckpt_path + ".tmp"    with open(tmp, "wb") as f:        pickle.dump(data, f)    os.replace(tmp, ckpt_path)def list_all_ids(index, namespace: str, checkpoint: dict, ckpt_path: str) -> list:    ckpt_key = f"ids:{namespace}"    if ckpt_key in checkpoint:        print(f"  Resuming ID listing for namespace='{namespace}' from checkpoint...")        return checkpoint[ckpt_key]    all_ids = []    cursor = None    page_num = 0    print(f"  Listing IDs for namespace='{namespace}'...")    while True:        kwargs = {"limit": LIST_BATCH_SIZE, "namespace": namespace}        if cursor:            kwargs["pagination_token"] = cursor        try:            resp = index.list_paginated(**kwargs)        except Exception as e:            print(f"  ERROR listing page {page_num}: {e}. Retrying in 5s...")            time.sleep(5)            continue        page_ids = [v.id for v in (resp.vectors or [])]        all_ids.extend(page_ids)        page_num += 1        if page_num % 100 == 0:            print(f"  ... {len(all_ids):,} IDs listed so far")        cursor = resp.pagination.next if resp.pagination else None        if not cursor:            break    print(f"  Found {len(all_ids):,} IDs in namespace='{namespace}'")    checkpoint[ckpt_key] = all_ids    save_checkpoint(ckpt_path, checkpoint)    return all_idsdef fetch_and_write(index, all_ids, namespace, output_file, checkpoint, ckpt_path):    ckpt_key = f"fetched_batches:{namespace}"    completed_batches = checkpoint.get(ckpt_key, set())    total_batches = math.ceil(len(all_ids) / FETCH_BATCH_SIZE)    new_writes = 0    with tqdm(total=len(all_ids), desc=f"  Fetching vectors (ns='{namespace}')") as pbar:        for batch_idx in range(total_batches):            start = batch_idx * FETCH_BATCH_SIZE            batch_ids = all_ids[start:start + FETCH_BATCH_SIZE]            if batch_idx in completed_batches:                pbar.update(len(batch_ids))                continue            # Fetch with exponential backoff retry            for attempt in range(5):                try:                    resp = index.fetch(ids=batch_ids, namespace=namespace)                    break                except Exception as e:                    wait = 2 ** attempt                    print(f"\n  Fetch attempt {attempt+1} failed: {e}. Waiting {wait}s...")                    time.sleep(wait)            else:                print(f"\n  ERROR: Could not fetch batch {batch_idx}. Skipping.")                pbar.update(len(batch_ids))                continue            vectors_map = resp.get("vectors", {})            for pid, pdata in vectors_map.items():                record = {                    "id":         pid,                    "values":     pdata.get("values", []),                    "metadata":   pdata.get("metadata", {}),                    "_namespace": namespace,                }                sv = pdata.get("sparse_values") or pdata.get("sparseValues")                if sv:                    record["sparse_values"] = {                        "indices": sv.get("indices", []),                        "values":  sv.get("values", []),                    }                output_file.write(json.dumps(record) + "\n")                new_writes += 1            completed_batches.add(batch_idx)            if batch_idx % 10 == 0:                checkpoint[ckpt_key] = completed_batches                save_checkpoint(ckpt_path, checkpoint)            pbar.update(len(batch_ids))            time.sleep(FETCH_RATE_SLEEP)    checkpoint[ckpt_key] = completed_batches    save_checkpoint(ckpt_path, checkpoint)    return new_writesdef main():    parser = argparse.ArgumentParser(description="Export Pinecone index to JSONL")    parser.add_argument("--api-key",    required=True)    parser.add_argument("--index-name", required=True)    parser.add_argument("--index-host", default=None,   help="Index host URL (optional, faster)")    parser.add_argument("--namespace",  default=None,   help="Export one namespace only")    parser.add_argument("--output",     default="pinecone_export.jsonl")    parser.add_argument("--checkpoint", default="pinecone_dump_checkpoint.pkl")    parser.add_argument("--fresh",      action="store_true", help="Ignore existing checkpoint")    args = parser.parse_args()    pc = Pinecone(api_key=args.api_key)    index = pc.Index(host=args.index_host) if args.index_host else pc.Index(args.index_name)    stats = index.describe_index_stats()    namespaces = list(stats.get("namespaces", {}).keys())    if not namespaces:        namespaces = [""]   # default namespace    if args.namespace:        namespaces = [args.namespace]    total_vectors = stats.get("total_vector_count", 0)    dimension     = stats.get("dimension", "unknown")    print(f"\nIndex: {args.index_name}")    print(f"  Dimension:     {dimension}")    print(f"  Total vectors: {total_vectors:,}")    print(f"  Namespaces:    {namespaces}")    print(f"  Output:        {args.output}\n")    checkpoint = {} if args.fresh else load_checkpoint(args.checkpoint)    total_written = 0    with open(args.output, "a", encoding="utf-8") as out_f:        for ns in namespaces:            print(f"Exporting namespace='{ns}'...")            ids     = list_all_ids(index, ns, checkpoint, args.checkpoint)            written = fetch_and_write(index, ids, ns, out_f, checkpoint, args.checkpoint)            total_written += written            print(f"  Done. {written:,} vectors written.\n")    print(f"Export complete! Total: {total_written:,} vectors -> {args.output}")    print(f"\nNext step: python qdrant_up.py --input {args.output}")if __name__ == "__main__":    main()

Run it like this:

pip install "pinecone>=3.0.0" tqdmpython pinecone_dumper.py \    --api-key pcsk_YOUR_KEY \    --index-name my-index \    --output my_vectors.jsonl

If it gets interrupted mid-run, just run it again …. it’ll skip everything it already fetched. The checkpoint file tracks progress at the batch level so you don’t re-fetch things you already have.

For very large indexes (100M+ vectors), this will take a while. The math: at 1,000 vectors per fetch and ~90 requests per second, you’re looking at roughly 90,000 vectors per second throughput. 100M vectors = ~18 minutes of pure fetch time, plus listing time on top. Run it overnight.

Okay, you’ve got your my_vectors.jsonl file. Now let's get it into Qdrant.

Step 1: Start Qdrant locally

docker run -d --name qdrant \    -p 6333:6333 -p 6334:6334 \    -v $(pwd)/qdrant_storage:/qdrant/storage \    qdrant/qdrant

The web UI is at http://localhost:6333/dashboard. It's actually quite nice - you can browse collections, run test queries, inspect points. Better than nothing!

Step 2: Recreate your collection

You need the dimension and metric from your Pinecone index. The describe_index_stats() call doesn't return the metric (annoyingly) - check your index creation code or the Pinecone console.

from qdrant_client import QdrantClientfrom qdrant_client.models import (    Distance, VectorParams, SparseVectorParams,    HnswConfigDiff, OptimizersConfigDiff)client = QdrantClient(url="http://localhost:6333")# For Qdrant Cloud:# client = QdrantClient(#     url="https://YOUR-CLUSTER.cloud.qdrant.io",#     api_key="YOUR_QDRANT_API_KEY"# )COLLECTION = "my_collection"DIMENSION  = 1536   # match your Pinecone index dimensionMETRIC     = "cosine"METRIC_MAP = {    "cosine":     Distance.COSINE,    "euclidean":  Distance.EUCLID,    "dotproduct": Distance.DOT,}# Disable HNSW during bulk load - re-enable after (3-5x faster ingest)client.create_collection(    collection_name=COLLECTION,    vectors_config=VectorParams(size=DIMENSION, distance=METRIC_MAP[METRIC]),    optimizers_config=OptimizersConfigDiff(indexing_threshold=0),)print(f"Created collection '{COLLECTION}'")

Notice indexing_threshold=0 - that disables HNSW index building during the bulk load. We'll re-enable it after. This makes 3-5x faster because Qdrant isn't trying to build the graph while you're flooding it with vectors.

Step 3: The up script

#!/usr/bin/env python3# Once again, have vibe coded this part to perfection!"""qdrant_up.pyReads a JSONL file from pinecone_dumper.py and bulk-upserts into Qdrant.Handles ID translation, batching, retries, and progress tracking.Requirements:    pip install "qdrant-client>=1.9.0" tqdmUsage:    python qdrant_up.py \        --input vectors.jsonl \        --collection my_collection \        --qdrant-url http://localhost:6333"""import argparseimport jsonimport hashlibimport timefrom pathlib import Pathfrom qdrant_client import QdrantClientfrom qdrant_client.models import (    PointStruct, SparseVector, Distance, VectorParams,    OptimizersConfigDiff, UpdateStatus)from tqdm import tqdmUPSERT_BATCH_SIZE = 512    # 256-512 is the sweet spotMAX_RETRIES       = 5def pinecone_id_to_qdrant(pinecone_id: str) -> str:    """    Convert a Pinecone string ID to a deterministic UUID for Qdrant.    We store the original ID in payload as _pinecone_id for reverse lookup.    """    h = hashlib.sha256(pinecone_id.encode()).hexdigest()    return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"def count_lines(filepath: str) -> int:    count = 0    with open(filepath, "rb") as f:        for _ in f:            count += 1    return countdef upsert_batch_with_retry(client, collection, points, max_retries=MAX_RETRIES):    for attempt in range(max_retries):        try:            result = client.upsert(                collection_name=collection,                points=points,                wait=True,            )            return result.status == UpdateStatus.COMPLETED        except Exception as e:            wait = 2 ** attempt            print(f"\n  Upsert attempt {attempt+1} failed: {e}. Waiting {wait}s...")            time.sleep(wait)    print(f"\n  ERROR: Batch failed after {max_retries} retries. Skipping {len(points)} points.")    return Falsedef build_point(record: dict, has_sparse: bool) -> PointStruct:    pinecone_id = record["id"]    qdrant_id   = pinecone_id_to_qdrant(pinecone_id)    values      = record.get("values", [])    payload = dict(record.get("metadata", {}))    payload["_pinecone_id"] = pinecone_id    payload["_namespace"]   = record.get("_namespace", "")    if has_sparse and "sparse_values" in record:        sv = record["sparse_values"]        vector = {            "dense":  values,            "sparse": SparseVector(                indices=sv.get("indices", []),                values=sv.get("values", [])            )        }    else:        vector = values    return PointStruct(id=qdrant_id, vector=vector, payload=payload)def main():    parser = argparse.ArgumentParser(description="Upload JSONL to Qdrant")    parser.add_argument("--input",       required=True)    parser.add_argument("--collection",  required=True)    parser.add_argument("--qdrant-url",  default="http://localhost:6333")    parser.add_argument("--api-key",     default=None, help="Qdrant API key (for cloud)")    parser.add_argument("--has-sparse",  action="store_true")    parser.add_argument("--batch-size",  type=int, default=UPSERT_BATCH_SIZE)    args = parser.parse_args()    client = QdrantClient(url=args.qdrant_url, api_key=args.api_key)    try:        info = client.get_collection(args.collection)        print(f"Collection '{args.collection}' found - {info.vectors_count or 0:,} existing vectors")    except Exception:        print(f"ERROR: Collection '{args.collection}' not found. Create it first.")        return    total_lines = count_lines(args.input)    print(f"\nInput file: {args.input} ({total_lines:,} records)")    print(f"Batch size: {args.batch_size}\n")    batch = []    total_uploaded = 0    total_failed   = 0    with open(args.input, "r", encoding="utf-8") as f, \         tqdm(total=total_lines, desc="Up", unit="vec") as pbar:        for line in f:            line = line.strip()            if not line:                continue            try:                record = json.loads(line)            except json.JSONDecodeError as e:                print(f"\n  Bad JSON line: {e}. Skipping.")                pbar.update(1)                continue            batch.append(build_point(record, args.has_sparse))            if len(batch) >= args.batch_size:                ok = upsert_batch_with_retry(client, args.collection, batch)                if ok:                    total_uploaded += len(batch)                else:                    total_failed += len(batch)                pbar.update(len(batch))                batch = []        if batch:            ok = upsert_batch_with_retry(client, args.collection, batch)            total_uploaded += len(batch) if ok else 0            total_failed   += len(batch) if not ok else 0            pbar.update(len(batch))    print(f"\nUpload complete! Uploaded: {total_uploaded:,}  Failed: {total_failed:,}")    # Re-enable HNSW indexing now that bulk load is done    print("\nRe-enabling HNSW indexing...")    client.update_collection(        collection_name=args.collection,        optimizers_config=OptimizersConfigDiff(indexing_threshold=20_000),    )    print("Waiting for index to build (this may take a few minutes)...")    while True:        info   = client.get_collection(args.collection)        status = str(info.status).lower()        if "green" in status:            break        print(f"  Status: {status}... waiting 15s")        time.sleep(15)    final_count = client.get_collection(args.collection).vectors_count    print(f"\nCollection '{args.collection}' is ready! Vector count: {final_count:,}")if __name__ == "__main__":    main()

Run the full pipeline:

pip install "qdrant-client>=1.9.0" tqdm# 1. Export from Pineconepython pinecone_dumper.py --api-key pcsk_XXX --index-name my-index --output vectors.jsonl# 2. Upload to local Qdrantpython qdrant_up.py \    --input vectors.jsonl \    --collection my_collection \    --qdrant-url http://localhost:6333

For Qdrant Cloud: sign up here, create a cluster (the free tier gets you 1M vectors permanently), then use:

python qdrant_up.py \    --input vectors.jsonl \    --collection my_collection \    --qdrant-url https://YOUR-CLUSTER.cloud.qdrant.io \    --api-key YOUR_QDRANT_KEY

Step 4: Verify it worked

from qdrant_client import QdrantClientclient = QdrantClient(url="http://localhost:6333")# Exact count (Qdrant's vectors_count is approximate for large collections)def exact_count(client, collection):    count, offset = 0, None    while True:        recs, next_offset = client.scroll(            collection_name=collection,            limit=1000,            offset=offset,            with_payload=False,            with_vectors=False        )        count += len(recs)        offset = next_offset        if offset is None:            break    return countn = exact_count(client, "my_collection")print(f"Qdrant has {n:,} vectors")# Spot-check: search and print original Pinecone IDs from payloadtest_vec = [0.1] * 1536  # replace with a real vectorresults = client.search("my_collection", query_vector=test_vec, limit=5, with_payload=True)for r in results:    print(f"  score={r.score:.4f}  original_pinecone_id={r.payload.get('_pinecone_id')}")

One more option: Qdrant’s official migration tool

Qdrant ships a Docker-based migration CLI that handles Pinecone directly. For serverless indexes, one command does the whole thing:

docker run --rm -it registry.cloud.qdrant.io/library/qdrant-migration pinecone \    --pinecone.index-host 'https://your-index.svc.pinecone.io' \    --pinecone.index-name 'your-index' \    --pinecone.api-key 'pcsk_...' \    --qdrant.url 'https://your-cluster.cloud.qdrant.io:6334' \    --qdrant.api-key 'your-qdrant-key' \    --qdrant.collection 'your-collection' \    --migration.batch-size 64

Note the gRPC port (6334): the migration tool uses gRPC, not the REST API. And it’s resumable via an internal _migration_offsets collection it creates on the target. If it gets killed, just rerun and it picks up from the last offset.

If you’re running a live production service on Pinecone, you can’t just turn it off run the migration and turn it back on. Here’s how to do it with zero downtime.

The core idea: run both databases simultaneously. Pinecone stays primary for reads. Qdrant gets all the writes. Once Qdrant has all the historical data and has proven itself with real traffic, you flip the switch.

import threadingfrom concurrent.futures import ThreadPoolExecutorfrom queue import Queue, Emptyclass DualWriteClient:    """    Wrap your existing Pinecone index to simultaneously write to Qdrant.    Pinecone is primary (writes block on Pinecone success).    Qdrant writes are async and non-blocking - failures queue for retry.    """    def __init__(self, pinecone_idx, qdrant_client, qdrant_collection):        self.pine   = pinecone_idx        self.qdrant = qdrant_client        self.col    = qdrant_collection        self.pool   = ThreadPoolExecutor(max_workers=4, thread_name_prefix="qdrant-write")        self.retry_q = Queue(maxsize=10_000)        self._start_retry_worker()    def upsert(self, vectors, namespace=""):        # Step 1: Write to Pinecone (primary, blocks until confirmed)        self.pine.upsert(vectors=vectors, namespace=namespace)        # Step 2: Write to Qdrant async (never blocks your app)        self.pool.submit(self._qdrant_upsert, vectors, namespace)    def _qdrant_upsert(self, vectors, namespace):        from qdrant_client.models import PointStruct        points = [            PointStruct(                id=pinecone_id_to_qdrant(v["id"]),                vector=v["values"],                payload={**v.get("metadata", {}), "_pinecone_id": v["id"], "_namespace": namespace}            )            for v in vectors        ]        try:            self.qdrant.upsert(collection_name=self.col, points=points, wait=True)        except Exception as e:            print(f"Qdrant write failed, queuing retry: {e}")            try:                self.retry_q.put_nowait(("upsert", points))            except Exception:                print(f"Retry queue full - {len(points)} points dropped")    def delete(self, ids, namespace=""):        self.pine.delete(ids=ids, namespace=namespace)        qdrant_ids = [pinecone_id_to_qdrant(i) for i in ids]        self.pool.submit(self._qdrant_delete, qdrant_ids)    def _qdrant_delete(self, ids):        try:            self.qdrant.delete(collection_name=self.col, points_selector=ids, wait=True)        except Exception as e:            try:                self.retry_q.put_nowait(("delete", ids))            except Exception:                pass    def _start_retry_worker(self):        t = threading.Thread(target=self._retry_loop, daemon=True)        t.start()    def _retry_loop(self):        while True:            try:                op, payload = self.retry_q.get(timeout=5)            except Empty:                continue            for attempt in range(5):                try:                    if op == "upsert":                        self.qdrant.upsert(collection_name=self.col, points=payload, wait=True)                    else:                        self.qdrant.delete(collection_name=self.col, points_selector=payload, wait=True)                    break                except Exception:                    time.sleep(2 ** attempt)# Usage: replace your existing pinecone_index with this one line# Before: results = pinecone_index.query(...)# After:  results = dual_client.pine.query(...)  <- reads still go to Pinecone## dual_client = DualWriteClient(pc.Index("my-index"), qdrant_client, "my_collection")# dual_client.upsert(vectors=[...])  <- writes go to both

Once dual-write is running, here’s the sequence:

Before you switch traffic to Qdrant: JUST VERI FY the following okay?

[x] Dual-write has been running >= 48 hours without errors[x] Backfill complete - Qdrant vector count within 0.1% of Pinecone count[x] Shadow comparison: mean Recall@10 >= 0.95 over at least 10,000 queries[x] Qdrant collection status = green (fully indexed)[x] P99 search latency on Qdrant within 15% of Pinecone[x] Rollback plan tested - know how to re-enable Pinecone reads in under 5 minutes

Qdrant has collection aliases so that a single API call switches an alias from one collection to another with zero gap. Use this for the cutover:

from qdrant_client.models import CreateAliasOperation, DeleteAliasOperation# One API call - no window where the alias is undefinedqdrant_client.update_collection_aliases(    change_aliases_operations=[        DeleteAliasOperation(delete_alias={"alias_name": "production"}),        CreateAliasOperation(create_alias={            "collection_name": "my_collection",            "alias_name": "production"        }),    ])# From this point: all reads going to "production" alias hit Qdrant

Keep dual-write running for at least 48 hours after full cutover. If something goes wrong, rollback is literally just updating a feature flag or load balancer weight to send reads back to Pinecone. Since dual-write is still active, Pinecone stays in sync during that window. Rolling back is instant and safe.

Okay… so I’ve been pretty technical so far. Let me just talk about what actually feels different when you’re using Qdrant versus Pinecone day-to-day.

The free tier is actually useful. Qdrant Cloud’s free tier is permanent and not a trial, and supports roughly 1M vectors on a real cluster

(0.5 vCPU, 1 GB RAM, 4 GB disk)!!!!!!

Pinecone’s free tier is 100,000 vectors on a single serverless index with the per-read-unit billing lurking. Qdrant’s free tier is just… a free cluster. No gotchas, no time limit.

NOTABLE MENTIONS FROM MY RESEARCH AND DUMP BELOW!

You can actually run it yourself. docker run -p 6333:6333 qdrant/qdrant. That's it. The self-hosted version and the cloud version run the same binary. No features are gated behind cloud-only access. The API is identical everywhere. You can move between local development, your own Kubernetes cluster, and Qdrant Cloud at any time. Your data is portable. Your skills transfer. Pinecone is SaaS-only, period.

Quantization is a superpower. Pinecone stores everything as float32. That’s 4 bytes per dimension, no exceptions. For a 10M vector / 1536-dim collection, that’s ~61 GB of raw vector data. With Qdrant’s Turbo4 4-bit datatype (from v1.19), you get that down to ~7.6 GB while maintaining solid recall. With binary quantization you’re under 2 GB. This is roughly a 30x memory reduction. At $5/GB/month in cloud RAM costs, that’s a very large number.

Quick cost comparison for 10M vectors at 1536 dims

Filterable HNSW is a real algorithmic difference. Pinecone applies filters as pre-filter or post-filter. If your filter is very selective (say, filter to 1% of vectors), post-filtering wastes a huge amount of graph traversal work, and pre-filtering with very small candidate sets loses recall. Qdrant integrates payload filtering directly into the HNSW graph traversal — it navigates the graph while respecting the filter in real-time. This gives predictable recall even on highly selective filters. It’s not a marketing claim — it’s a fundamentally different query execution model.

Multi-vector per point. Each Qdrant point can have multiple named vectors. One point can carry a text embedding (768-dim), an image embedding (512-dim), and a sparse BM25 vector, all stored together with their payload. A single hybrid search can fuse all three with RRF. Pinecone gives you one vector per record. Full stop. This matters a lot for multimodal and hybrid retrieval workloads.

Data portability is the anti-vendor-lock-in story. With Qdrant, you can snapshot any collection and download it:

That snapshot is your data. You can restore it to any Qdrant instance anywhere. If Qdrant doubles their prices tomorrow, you have a complete copy of your data ready to move. With Pinecone, you do the LIST+FETCH dance described in Section 3. If Pinecone has an outage and you can’t list your IDs… well.

Open source, for real. Qdrant has 34,000+ GitHub stars, Apache 2.0 license, and the team actively responds to issues. You can read the source code, understand exactly what’s happening with your data, file bugs, send PRs. When something weird happens with your search results, you can actually dig in and find out why. Pinecone is a black box. That distinction sounds abstract until something goes wrong.

The Qdrant documentation covers all of this in depth — quantization options, memory tier configs, distributed deployment, the lot. And if you want to follow what’s coming next (new quantization research, benchmark results, engineering deep-dives), the newsletter is worth subscribing to. They publish real engineering content, not just product announcements.

Alright now! that’s the full picture. The migration is doable okay and most teams do it over a week or two with zero downtime using the dual-write approach. The scripts handle the heavy lifting. The main gotchas to keep in mind:

Good luck with the migration. You’ve got this.

Migrate Pinecone to Qdrant: Complete Migration Guide | Zero Heart Burns was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.

── more in #ai-infrastructure 4 stories · sorted by recency
── more on @pinecone 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/migrate-pinecone-to-…] indexed:0 read:18min 2026-08-28 ·