Skip to main content
This guide shows you how to migrate a vector-search workload from pgvector to Pinecone Database while keeping PostgreSQL available as the source of truth until cutover. It’s based on the pgvector-migration tool, which provides a step-by-step migration manual, a runnable notebook, and a synchronization script that supports backfills, incremental synchronization, and reconciliation.

Before you migrate

You need:
  • A Pinecone account and API key.
  • Network access to your PostgreSQL database.
  • Python 3.9 or later.
  • A PostgreSQL role that can read the tables you want to migrate.
  • Write access to PostgreSQL if you use the synchronization script. The script creates bookkeeping tables or an outbox table and triggers, depending on the synchronization strategy.
Clone the migration repository and install its dependencies:
Terminal
The repository’s requirements.txt installs the pinecone, psycopg, pgvector, and pyarrow packages used throughout this guide.
Set the connection string, API key, and Pinecone index name as environment variables:
Terminal
Complete the workflow first in a test or staging environment using a disposable Pinecone index and representative source data. Delete the disposable index after validation. When you repeat the workflow in production, create a new, empty production index and don’t stream pilot records into its target namespaces. Bulk import requires those namespaces not to exist.
The migration_walkthrough.ipynb notebook runs every step in this guide against a local Dockerized pgvector database, so you can test the workflow before running it against your own data.

1. Inspect the pgvector data

For every table you want to migrate, identify the primary key, vector column, vector dimension, distance metric, metadata columns, and row count. Find the vector columns and their dimensions:
PostgreSQL
Inspect each pgvector index to determine the distance metric:
PostgreSQL
Map the pgvector operator class to the Pinecone metric: If the table doesn’t have a pgvector index, inspect the distance operator used by your application queries. The Pinecone index dimension and metric must match the source data. Also record the source count for each table. You’ll compare these counts with Pinecone after the backfill:
PostgreSQL

2. Map access controls

If your tables use PostgreSQL row-level security (RLS), review every policy before choosing a Pinecone layout:
PostgreSQL
Pinecone doesn’t evaluate PostgreSQL RLS policies. Use a namespace for a hard tenant boundary, or copy access-control attributes into record metadata and apply a server-side metadata filter to every search. Derive the namespace or filter values from the authenticated session, not from client input. If a policy depends on joins, functions, or data outside the vector row, enforce the access decision in your application. When you denormalize permissions into metadata, synchronize permission changes as well as changes to the vector table.

3. Choose the index and namespace layout

By default, sync.py uses one Pinecone index and maps each PostgreSQL table to a namespace with the same name. The steps below use this layout. Tables can share an index only when their vectors have the same dimension and use the same distance metric. To use a shared namespace, tenant-based namespaces, or multiple indexes, update the namespace and index routing in sync.py before you run the migration. Use a string for every record ID. The migration repository prefixes source IDs with the table name, such as documents#4021, to prevent collisions. Each Pinecone record contains the vector in values and selected source columns in metadata. Metadata values must be strings, numbers, booleans, or lists of strings. Omit NULL values and convert PostgreSQL numeric values to floats. Don’t name a source metadata field metadata; metadata is the top-level Parquet column that contains the JSON-encoded metadata object.

4. Create the Pinecone index

The examples below use two PostgreSQL tables, documents and products, with 768-dimensional vectors stored in an embedding column and queried using cosine distance. Replace all example table names, column names, dimensions, metrics, metadata fields, and namespaces with values from your workload.
Create a new, empty production serverless index with the dimension and metric you identified from pgvector:
Python
Replace the example dimension, metric, cloud, and region with values appropriate for your workload. If separate source tables have different dimensions or metrics, create separate indexes.

5. Configure the source tables

In the cloned migration repository, edit the TABLES configuration near the top of sync.py to match your PostgreSQL schema. Each entry defines the primary key, vector column, metadata columns, and optional change timestamp:
Python
By default, sync.py maps each configured table to a namespace with the same name. Run all sync.py commands below from the root of the cloned pgvector-migration repository.

6. Backfill the records

Initialize change tracking

The following workflow uses the default change-log strategy. If the source stays writable during the backfill, initialize change tracking before copying records. This captures changes made during the backfill so you can apply them afterward:
Terminal

Bulk import

For a production backfill, use bulk import. Create an export.py file with the following code from the tool’s migration workflow. It writes one import-ready Parquet file per table.
The export script reuses the PostgreSQL connection, TABLES configuration, and record-mapping helpers from sync.py. Configure TABLES in sync.py before running the export.
Python
Run the export:
Terminal
The script creates one directory per namespace and one Parquet file per table:
Output
If a table would produce a file larger than the maximum file size, split it into numbered files in the same namespace directory. Review the import limits before exporting a large dataset. Upload the generated directory tree to Amazon S3, Google Cloud Storage, or Azure Blob Storage. Then start an import using the import root URI:
Python
Use the import ID with the describe_import operation to monitor progress. Each import takes at least 10 minutes. Wait until the import status is Completed before validating record counts in the next step. The target namespaces must not already exist. A private bucket or container requires a storage integration.

Streaming backfill

For a small dataset, you can instead use the streaming backfill in sync.py:
Terminal
Streaming upsert requests are limited by both record count and request size. The script uses batches of 200 records to stay below the 2 MB request limit for its 768-dimensional example. Reduce the batch size for larger vectors or metadata.

Apply captured changes

If you initialized change tracking, apply the changes captured while the backfill was running:
Terminal

7. Validate the backfill

Compare the source row count for each table with the record count for its target namespace:
Python
Index statistics are eventually consistent, so allow time for the counts to update. Next, run a representative set of query vectors against pgvector and Pinecone. For example, the following code compares the top 10 results for a vector selected from the documents table:
Python
Replace the table, columns, namespace, and pgvector operator with values for your workload. Use <=> for cosine distance, <#> for inner product, or <-> for Euclidean distance. Compare the returned record IDs and ordering. Small ordering differences for near-ties are expected with approximate search. Large differences can indicate a mismatched metric or inconsistent vector normalization.

8. Keep Pinecone synchronized

After the initial copy, synchronize inserts, updates, and deletes until cutover. The repository’s sync.py supports two strategies: The change-log strategy is the default. Run the synchronization command on a schedule until cutover:
Terminal
The PostgreSQL role used by the script must be able to create the outbox table, functions, and triggers, and update the outbox table. For workloads that are mostly inserts and have a maintained updated_at column, the tool also supports a watermark strategy. This strategy uses its first synchronization run to backfill records. For setup, limitations, and commands, see the watermark strategy in the pgvector-migration tool. Periodically reconcile all record IDs, and run reconciliation immediately before cutover:
Terminal
Reconciliation upserts records missing from Pinecone and deletes records that no longer exist in pgvector.

9. Cut over application traffic

Keep pgvector serving traffic and continue synchronization while you cut over:
  1. Shadow a sample of production searches to Pinecone without returning those results to users. Compare search quality and latency.
  2. Route a small percentage of reads to Pinecone and monitor results.
  3. Increase the percentage after validation.
  4. Immediately before switching the primary read path, run sync with your selected strategy, run python sync.py reconcile, and then run sync again to apply changes captured during reconciliation.
  5. Run sync once more and confirm that it reports no outstanding changes. Then make Pinecone the primary read path.
  6. Keep pgvector available as a rollback target until the new read path is stable. You can point reads back to pgvector as it never stopped serving and is still authoritative.
For implementation details, troubleshooting, and the complete runnable workflow, see the pgvector-migration tool.