· drizzle / postgres / pgvector

How to Set Up Drizzle ORM with Postgres and pgvector

Set up Drizzle ORM with Postgres 16 and pgvector. Covers the CREATE EXTENSION step most guides skip and the cosine distance pattern that uses the HNSW index.

By

1,609 words · 9 min read

Drizzle 0.45.2 with postgres.js on Postgres 16 is the stack to reach for. Two things trip people up before they see a working query: the vector extension doesn’t install itself, and the cosine distance query pattern that looks correct will silently skip your HNSW index. This guide covers both, with the exact commands to run.

Who this is for

TypeScript developers adding embedding search to a Postgres database. You should have a working Node.js 18+ project with TypeScript. If you haven’t settled on which ORM to use, Drizzle vs Kysely and Prisma vs Drizzle cover the trade-offs — this guide assumes you’ve picked Drizzle and are wiring it to Postgres with pgvector.

What we tested

  • drizzle-orm 0.45.2
  • drizzle-kit 0.31.10
  • postgres (postgres.js) 3.x
  • pgvector 0.8.2
  • PostgreSQL 16
  • Node.js 22

Step 1: Prerequisites

You need:

  • Node.js 18+
  • PostgreSQL 16 (13+ is supported, 16 is recommended)
  • The pgvector extension available on your instance

The fastest way to get a local Postgres 16 with pgvector pre-installed:

docker run -d \
  --name pgvector-dev \
  -e POSTGRES_PASSWORD=postgres \
  -p 5432:5432 \
  pgvector/pgvector:pg16

This uses the official pgvector Docker image. The extension is compiled in — you still need to enable it per-database (Step 4), but it’s available.

Failure mode: If you’re on a hosted provider (RDS, Cloud SQL, AlloyDB), confirm pgvector is available on your tier before starting. AWS RDS Postgres supports pgvector on all instance types running PostgreSQL 15.3+ (source); note that db.t3.micro has only 1 GB RAM, which limits HNSW indexing performance under load. pgvector is a supported extension in Google Cloud SQL — run CREATE EXTENSION IF NOT EXISTS vector; via psql or Cloud Shell to enable it (source). Checking this now saves you a failed migration later.

Step 2: Install packages

npm install [email protected] postgres
npm install -D [email protected] tsx

postgres is postgres.js — a well-supported driver with first-class TypeScript types that integrates cleanly with Drizzle’s connection pooling. Drizzle also supports node-postgres (see Drizzle docs), but postgres.js is lighter and the driver used throughout this guide.

Failure mode: Installing drizzle-orm without pinning the version may give you something older than 0.31.0. That release added pgvector column support. Earlier versions don’t know about the vector() column type, and you’ll get a type error when you try to define the schema. Always pin when pgvector is involved.

Step 3: Configure Drizzle

Create drizzle.config.ts at the project root:

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

Add your database URL to .env:

DATABASE_URL=postgres://postgres:postgres@localhost:5432/mydb

Failure mode: If you set dialect: 'mysql' or dialect: 'sqlite', pgvector column types won’t be available. They’re Postgres-only. The TypeScript types will still compile, but the generated SQL will be wrong.

Step 4: Enable the pgvector extension

This is the step most tutorials skip. Neither drizzle-kit push nor drizzle-kit migrate runs CREATE EXTENSION for you. You must do it before any migration that creates a vector column.

Create drizzle/0000_enable_vector.sql:

CREATE EXTENSION IF NOT EXISTS vector;

Run it against your database:

psql $DATABASE_URL -f drizzle/0000_enable_vector.sql

If you prefer to keep everything in code, you can run it via the Drizzle client before your first migration:

import postgres from 'postgres';

const sql = postgres(process.env.DATABASE_URL!);
await sql`CREATE EXTENSION IF NOT EXISTS vector`;
await sql.end();

Failure mode: Skip this step and your first migration throws type "vector" does not exist. The migration halts, and Drizzle’s migration state becomes inconsistent — it records the migration as failed, but some earlier SQL in the same file may have partially applied. Fix: run CREATE EXTENSION IF NOT EXISTS vector; directly on the database, drop the partial table if it was created, then re-run the migration.

Step 5: Define the schema

Create src/schema.ts:

import { pgTable, serial, text, index } from 'drizzle-orm/pg-core';
import { vector } from 'drizzle-orm/pg-core';

export const documents = pgTable(
  'documents',
  {
    id: serial('id').primaryKey(),
    content: text('content').notNull(),
    embedding: vector('embedding', { dimensions: 1536 }),
  },
  (table) => [
    index('documents_embedding_idx')
      .using('hnsw', table.embedding.op('vector_cosine_ops')),
  ]
);

1536 dimensions matches OpenAI’s text-embedding-3-small. Change this to match whatever model you’re using — Cohere’s embed-english-v3 outputs 1024, for example.

The HNSW index with vector_cosine_ops tells pgvector which distance function your queries will use. You declare it at index creation time. The planner can’t infer it from the query at runtime.

Failure mode: If you’re adding a vector column to an existing table via ALTER TABLE, use drizzle-orm 0.31.0+. Earlier versions generated ALTER TABLE statements that quoted the type as "vector(1536)" — with quotes — causing Postgres to throw type "vector(1536)" does not exist. The fix is upgrading drizzle-orm. For new tables, define the vector column in the initial CREATE TABLE and you avoid the ALTER TABLE path entirely.

Step 6: Generate and run migrations

Generate the migration:

npx drizzle-kit generate

This writes a .sql file to ./drizzle. Open it and verify it contains a CREATE TABLE with the vector column and a CREATE INDEX for the HNSW index before you run it.

Run the migration:

npx drizzle-kit migrate

For programmatic migration at app startup, create a src/migrate.ts script:

import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';

const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
await migrate(drizzle(migrationClient), { migrationsFolder: './drizzle' });
await migrationClient.end();

Run it with: npx tsx src/migrate.ts

Failure mode: drizzle-kit push skips migration files entirely and applies schema changes directly. This is fine for local iteration but wrong for production — there’s no audit trail, no rollback path, and no way to replay the migration on a fresh database. If you’ve been using push in development and switch to migrate in production, the migration state table won’t match the schema that’s already applied. Start with generate + migrate from the beginning, even in dev.

Step 7: Set up the database client

Create src/db.ts:

import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';

const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });

Failure mode: If you’re running behind AWS RDS Proxy, disable prepared statements. postgres.js enables them by default; RDS Proxy pins a backend connection per client connection when prepared statements are in use, which eliminates the pooling benefit (AWS docs). Disable with:

const client = postgres(process.env.DATABASE_URL!, { prepare: false });

Step 8: Insert embeddings

import { db } from './db';
import { documents } from './schema';

// Call your embedding model — OpenAI, Cohere, Ollama, etc.
const embedding: number[] = await getEmbedding('Hello, world');

await db.insert(documents).values({
  content: 'Hello, world',
  embedding,
});

The embedding column accepts a plain JavaScript array of floats. No special serialization needed — Drizzle and postgres.js handle the conversion to pgvector’s wire format.

Failure mode: Inserting an array with the wrong length throws expected 1536 dimensions, not N. The dimension count in the schema definition must exactly match the model’s output size. Check your model’s documentation and update the schema if needed.

Step 9: Query by similarity

The index fires or doesn’t based on how you write the orderBy clause.

Correct — HNSW index is used:

import { asc } from 'drizzle-orm';
import { cosineDistance } from 'drizzle-orm';
import { db } from './db';
import { documents } from './schema';

const queryEmbedding = await getEmbedding('search query');

const results = await db
  .select()
  .from(documents)
  .orderBy(asc(cosineDistance(documents.embedding, queryEmbedding)))
  .limit(10);

Wrong — HNSW index is skipped:

// Don't do this
import { desc, sql } from 'drizzle-orm';

const results = await db
  .select()
  .from(documents)
  .orderBy(desc(sql`1 - ${cosineDistance(documents.embedding, queryEmbedding)}`))
  .limit(10);

The 1 - cosineDistance(...) pattern computes cosine similarity rather than cosine distance. The index is built on the distance function — Postgres can’t use it when the expression is inverted. orderBy(asc(cosineDistance(...))) returns identical results (smallest distance first = highest similarity first) and uses the index.

Failure mode: Without the index, Postgres does a sequential scan. At 10k rows you won’t notice. At 500k rows your queries run in seconds instead of milliseconds. Confirm the index is in use with EXPLAIN ANALYZE:

psql $DATABASE_URL -c "EXPLAIN ANALYZE SELECT * FROM documents ORDER BY embedding <=> '[0.1, 0.2, 0.3]'::vector LIMIT 10;"

Look for Index Scan using documents_embedding_idx in the output. If you see Seq Scan, the index isn’t firing — re-check the query pattern and confirm the HNSW index was created.

Next steps

For managed Postgres with pgvector already enabled, Supabase and Neon both support it out of the box — no Docker, no extension setup:

  • Supabase enables pgvector on all plans and includes a table editor that displays vector columns. Row Level Security works with vector queries without any extra configuration, which matters if you’re building per-user document search.
  • Neon pairs well with the migration approach described here — it supports branch-per-PR workflows, so each pull request gets a database branch with migrated schema and no shared state between test runs.
  • Railway is a reasonable option if you prefer less-managed Postgres and want to host your own pgvector version.

If you’re deciding between Supabase and Neon for hosting, Neon vs Supabase compares their pricing, branching, and connection pooling.

Drizzle Studio gives you a browser-based GUI to inspect tables and run queries against your dev database without leaving the terminal ecosystem:

npx drizzle-kit studio

There’s also an ongoing v1.0.0-beta release series from the Drizzle team — the API surface covered here is stable across it, but watch the changelog before upgrading if you have custom SQL fragments.