Skip to main content

NestJS + Drizzle ORM + PostgreSQL

A practical guide for setting up Drizzle ORM with PostgreSQL in an existing NestJS project.

This project previously had TypeORM installed, but TypeORM was not fully configured:

  • No TypeORM migrations existed.
  • No TypeORM database schema had been established.
  • No existing TypeORM migration history needs to be preserved.

Therefore, Drizzle will become the first database schema and migration system for the project.

1. Architecture​

The project uses the following architecture:

PostgreSQL
▲
│
pg
│
Pool
│
▼
Drizzle ORM
│
┌─────────┴─────────┐
│ │
Schema Relations
│ │
└─────────┬─────────┘
│
▼
DatabaseModule
│
▼
NestJS

There are two separate concerns.

Drizzle Kit​

Drizzle Kit is responsible for:

Schema definitions
↓
Migration generation
↓
SQL migrations
↓
PostgreSQL

Drizzle ORM​

Drizzle ORM is used by the running NestJS application:

NestJS Service
↓
Drizzle ORM
↓
PostgreSQL
warning

These two concerns should not be confused.

  • drizzle.config.ts configures Drizzle Kit.
  • The NestJS DatabaseModule configures the runtime Drizzle connection.

2. Install Dependencies​

From the root of the NestJS project:

npm install drizzle-orm pg
npm install -D drizzle-kit @types/pg
PackagePurpose
drizzle-ormDrizzle ORM used by the application
pgPostgreSQL driver for Node.js
drizzle-kitDrizzle CLI and migration tooling
@types/pgTypeScript definitions for pg

3. Environment Configuration​

Create a .env file in the project root:

.env
DATABASE_URL=postgresql://postgres:password@localhost:5432/my_database

Replace the credentials and database name with the actual PostgreSQL configuration. For example:

DATABASE_URL=postgresql://postgres:mysecret@localhost:5432/task_manager

The application and Drizzle Kit will use this connection string.

3.1 .gitignore​

Make sure .env is not committed:

.gitignore
.env

A useful .env.example can be committed:

.env.example
DATABASE_URL=

This documents which environment variables are required without exposing credentials.

4. Drizzle Configuration​

Create drizzle.config.ts at the project root.

The project should look approximately like:

project/
├── src/
├── package.json
├── tsconfig.json
├── nest-cli.json
├── drizzle.config.ts
└── .env

Create:

drizzle.config.ts
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

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

Configuration explained​

schema​

schema: './src/database/schema/**/*.schema.ts',

This tells Drizzle Kit where the database schema definitions are located. For example:

src/
└── database/
└── schema/
├── users.schema.ts
├── posts.schema.ts
└── comments.schema.ts

The **/*.schema.ts glob pattern allows additional schema files to be added later.

out​

out: './drizzle',

This is where Drizzle Kit generates migration files. For example:

drizzle/
├── 0000_initial.sql
└── meta/

The migration files should generally be generated by Drizzle Kit rather than manually created.

dialect​

dialect: 'postgresql',

This tells Drizzle Kit that PostgreSQL is being used.

dbCredentials​

dbCredentials: {
url: process.env.DATABASE_URL!,
},

This tells Drizzle Kit how to connect to PostgreSQL when migrations are being applied.

5. Configure NestJS​

The project uses a dedicated DatabaseModule. The resulting structure is:

src/
└── database/
├── database.module.ts
├── database.provider.ts
├── relations.ts
└── schema/
├── index.ts
└── users.schema.ts

6. Database Provider​

Create src/database/database.provider.ts. The current implementation:

src/database/database.provider.ts
import { Provider } from '@nestjs/common';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { relations } from './relations';

const createDb = () => {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});

return drizzle({
client: pool,
relations,
});
};

export type Database = ReturnType<typeof createDb>;

export const DATABASE = Symbol('DATABASE');

export const databaseProvider: Provider = {
provide: DATABASE,
useFactory: createDb,
};

Why use a provider?​

NestJS uses dependency injection. The database provider allows services to request the Drizzle instance instead of creating their own database connections.

Conceptually:

UsersService
│
│ @Inject(DATABASE)
▼
Database Provider
│
▼
Drizzle
│
▼
PostgreSQL

7. Database Module​

Create src/database/database.module.ts:

src/database/database.module.ts
import { Global, Module } from '@nestjs/common';
import { databaseProvider } from './database.provider';

@Global()
@Module({
providers: [databaseProvider],
exports: [databaseProvider],
})
export class DatabaseModule {}

Why @Global()?​

The database is a shared infrastructure dependency. With @Global(), other modules can inject the database without repeatedly importing DatabaseModule.

For example, all of these can use the database provider:

  • UsersModule
  • PostsModule
  • AuthModule
  • TasksModule
  • CommentsModule

8. Register the Database Module​

In app.module.ts:

src/app.module.ts
import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';

@Module({
imports: [
DatabaseModule,

// Other modules...
],
})
export class AppModule {}

If the application already uses ConfigModule, keep it as well. For example:

src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),

DatabaseModule,
],
})
export class AppModule {}

9. Verify NestJS Startup​

Start the application:

npm run start:dev

A successful startup should look approximately like:

[Nest] Starting Nest application...
[Nest] AppModule dependencies initialized
[Nest] DatabaseModule dependencies initialized
[Nest] Nest application successfully started

At this point the NestJS database provider is successfully initialized.

10. Create the Database Schema​

Drizzle schema files define the PostgreSQL database structure.

Create src/database/schema/users.schema.ts. The first table:

src/database/schema/users.schema.ts
import {
pgTable,
uuid,
varchar,
timestamp,
} from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),

email: varchar('email', {
length: 255,
}).notNull().unique(),

name: varchar('name', {
length: 255,
}).notNull(),

createdAt: timestamp('created_at')
.defaultNow()
.notNull(),

updatedAt: timestamp('updated_at')
.defaultNow()
.notNull(),
});

11. Understanding the users Schema​

The following:

export const users = pgTable('users', {

defines a PostgreSQL table named users.

ID​

id: uuid('id').defaultRandom().primaryKey(),

Creates:

"id" uuid PRIMARY KEY DEFAULT gen_random_uuid()

The ID is:

  • a UUID
  • the primary key
  • automatically generated

Therefore an insert does not need to manually provide an ID.

Email​

email: varchar('email', {
length: 255,
}).notNull().unique(),

Creates:

"email" varchar(255) NOT NULL UNIQUE

This means:

  • Maximum length is 255 characters.
  • NULL is not allowed.
  • Duplicate emails are not allowed.

Name​

name: varchar('name', {
length: 255,
}).notNull(),

Creates:

"name" varchar(255) NOT NULL

Created timestamp​

createdAt: timestamp('created_at')
.defaultNow()
.notNull(),

The TypeScript property is createdAt, while the PostgreSQL column is created_at. This allows the application to use camelCase while the database uses snake_case.

Updated timestamp​

updatedAt: timestamp('updated_at')
.defaultNow()
.notNull(),

This creates:

"updated_at" timestamp DEFAULT now() NOT NULL
note

defaultNow() sets the initial value. It does not automatically update the column whenever the row changes. If automatic update behavior is required later, it should be explicitly implemented.

12. Schema Index​

Create src/database/schema/index.ts:

src/database/schema/index.ts
export * from './users.schema';

As more tables are added:

src/database/schema/index.ts
export * from './users.schema';
export * from './posts.schema';
export * from './tasks.schema';
export * from './comments.schema';

This gives the database layer a central schema export.

13. Relations​

Create src/database/relations.ts. Current implementation:

src/database/relations.ts
import { defineRelations } from 'drizzle-orm';
import * as schema from './schema';

export const relations = defineRelations(schema, () => ({}));

At the moment there are no relationships, so the relation definition is empty.

For example, later we might have:

users
│
├── tasks
│
└── projects

and relations can then be defined.

Keeping the relation configuration separate from individual schema files keeps the database layer organized as the project grows.

14. Connecting Schema to Runtime Drizzle​

The database provider imports the schema through the relations configuration:

import { relations } from './relations';

and initializes Drizzle:

return drizzle({
client: pool,
relations,
});

The flow is:

schema/index.ts
│
▼
relations.ts
│
▼
database.provider.ts
│
▼
Drizzle ORM

This allows the runtime Drizzle instance to know about the application's schema and relations.

15. Generate the First Migration​

Once the schema exists, generate the migration:

npm run db:generate

If the npm script has not been configured, this can also be run directly:

npx drizzle-kit generate

Drizzle Kit reads src/database/schema/ and generates SQL migrations under drizzle/.

The migration generated for the initial users schema is:

CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"email" varchar(255) NOT NULL UNIQUE,
"name" varchar(255) NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);

This is the SQL that will create the database table.

16. Apply the Migration​

Generate and apply are two separate operations.

Generate:

npm run db:generate

Apply:

npm run db:migrate

The overall process is:

users.schema.ts
│
│ db:generate
▼
migration.sql
│
│ db:migrate
▼
PostgreSQL
info

This distinction is important:

  • generate does not modify the database.
  • migrate executes the generated migration against PostgreSQL.

Add the following to package.json:

package.json
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
}
}

Then the common commands are:

# Generate migration
npm run db:generate

# Apply migrations
npm run db:migrate

# Push schema directly
npm run db:push

# Open Drizzle Studio
npm run db:studio

18. Migration vs Push​

These commands serve different purposes.

db:generate​

Creates migration files:

npm run db:generate

Use this when you want schema changes represented as version-controlled migrations.

db:migrate​

Applies generated migrations:

npm run db:migrate

This is the normal migration workflow.

db:push​

Directly pushes the schema to the database without the normal migration workflow:

npm run db:push
caution

db:push can be useful during rapid local prototyping, but a project that relies on migrations should generally use generate → migrate for tracked database changes.

19. Injecting Drizzle into a NestJS Service​

Once the database provider is registered, it can be injected into services. For example:

src/users/users.service.ts
import { Inject, Injectable } from '@nestjs/common';
import {
DATABASE,
Database,
} from '../database/database.provider';

@Injectable()
export class UsersService {
constructor(
@Inject(DATABASE)
private readonly db: Database,
) {}
}

The important pieces are @Inject(DATABASE) and the Database type.

The Database type comes from:

export type Database = ReturnType<typeof createDb>;

This allows TypeScript to understand the actual Drizzle database instance.

20. Querying Users​

Import the schema:

import { users } from '../database/schema';

Then:

async findAll() {
return this.db.query.users.findMany();
}

This uses Drizzle's relational query API.

The database flow is now:

UsersService
│
▼
this.db.query.users.findMany()
│
▼
Drizzle ORM
│
▼
PostgreSQL
│
▼
users table

21. Creating a User​

A user can be inserted using:

async create(email: string, name: string) {
const [user] = await this.db
.insert(users)
.values({
email,
name,
})
.returning();

return user;
}

The ID does not need to be provided because the schema defines:

id: uuid('id').defaultRandom().primaryKey(),

Likewise, createdAt and updatedAt have database defaults.

The resulting insert is conceptually:

INSERT INTO users (
email,
name
)
VALUES (
...,
...
)
RETURNING *;

22. Current Users Service​

At this point, a simple UsersService can look like:

src/users/users.service.ts
import { Inject, Injectable } from '@nestjs/common';
import {
DATABASE,
Database,
} from '../database/database.provider';
import { users } from '../database/schema';

@Injectable()
export class UsersService {
constructor(
@Inject(DATABASE)
private readonly db: Database,
) {}

async findAll() {
return this.db.query.users.findMany();
}

async create(email: string, name: string) {
const [user] = await this.db
.insert(users)
.values({
email,
name,
})
.returning();

return user;
}
}

This is enough to establish the first end-to-end database operation.

23. Query by Email​

Drizzle can also perform filtered queries. For example:

async findByEmail(email: string) {
return this.db.query.users.findFirst({
where: (users, { eq }) =>
eq(users.email, email),
});
}

This gives the service a typed way to retrieve a user.

24. Drizzle vs TypeORM Architecture​

Because this project previously used TypeORM, it is tempting to reproduce the exact same architecture. That is not necessary.

A TypeORM application might commonly look like:

UsersService
↓
UsersRepository
↓
TypeORM Repository<User>
↓
PostgreSQL

With Drizzle, a simpler structure is often sufficient:

UsersService
↓
Drizzle
↓
PostgreSQL

For example:

async findByEmail(email: string) {
return this.db.query.users.findFirst({
where: (users, { eq }) =>
eq(users.email, email),
});
}

There is no requirement to introduce a repository abstraction if it doesn't provide useful value. Repositories can still be introduced later if the application's data-access logic becomes complex enough to justify them.

25. Current Project Structure​

At this stage, the project can look like:

project/
├── drizzle/
│ ├── 0000_*.sql
│ └── meta/
│
├── src/
│ ├── database/
│ │ ├── database.module.ts
│ │ ├── database.provider.ts
│ │ ├── relations.ts
│ │ │
│ │ └── schema/
│ │ ├── index.ts
│ │ └── users.schema.ts
│ │
│ ├── users/
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ └── users.module.ts
│ │
│ ├── app.module.ts
│ └── main.ts
│
├── .env
├── .env.example
├── drizzle.config.ts
├── package.json
└── tsconfig.json

26. Database Layer Responsibilities​

A useful rule for this project is:

schema/​

Defines the database structure.

schema/
├── users.schema.ts
├── tasks.schema.ts
├── projects.schema.ts
└── ...

relations.ts​

Defines relationships between tables.

users ↔ tasks
users ↔ projects
projects ↔ tasks

database.provider.ts​

Creates the runtime Drizzle instance.

PostgreSQL Pool
↓
Drizzle

database.module.ts​

Makes the database available through NestJS dependency injection.

drizzle.config.ts​

Configures Drizzle Kit for schema discovery and migrations.

27. The Development Workflow​

When adding a new table:

1. Create/update schema
↓
2. Generate migration
↓
3. Review migration
↓
4. Apply migration
↓
5. Use schema from NestJS services

For example:

# Modify schema

npm run db:generate

# Review generated SQL

npm run db:migrate

# Use the new table from NestJS

28. Example: Adding Another Table​

Suppose we later add tasks.

We would create src/database/schema/tasks.schema.ts and define the table:

src/database/schema/tasks.schema.ts
export const tasks = pgTable('tasks', {
// columns...
});

Export it:

src/database/schema/index.ts
export * from './users.schema';
export * from './tasks.schema';

Then generate a migration:

npm run db:generate

Review the generated SQL. Then:

npm run db:migrate

The database is updated.

29. Important Principle: Schema First​

The database schema should be treated as an important source of truth. For example:

email: varchar('email', {
length: 255,
}).notNull().unique(),

communicates several database rules:

email
├── varchar(255)
├── required
└── unique

These constraints belong in the database rather than relying exclusively on application-level validation.

Application DTO validation and database constraints solve different problems:

DTO validation
↓
Protect application input

Database constraint
↓
Protect database integrity

Both can be used.

30. Current Status​

The project has now established:

  • PostgreSQL as the database
  • Drizzle ORM as the ORM
  • Drizzle Kit as the migration tool
  • A DATABASE_URL environment variable
  • drizzle.config.ts
  • A NestJS DatabaseModule
  • A NestJS Drizzle provider
  • A typed Database type
  • A schema directory
  • A users table schema
  • A schema index
  • A relations configuration
  • The first generated migration
  • The ability to apply migrations to PostgreSQL

The current database flow is:

┌───────────────────────────┐
│ users.schema.ts │
│ │
│ PostgreSQL table │
│ definition │
└─────────────┬─────────────┘
│
│ drizzle-kit generate
▼
┌───────────────────────────┐
│ drizzle/ │
│ │
│ SQL migration │
└─────────────┬─────────────┘
│
│ drizzle-kit migrate
▼
┌───────────────────────────┐
│ PostgreSQL │
│ │
│ users table │
└───────────────────────────┘

And the runtime application flow is:

┌───────────────────────────┐
│ NestJS │
│ │
│ UsersService │
└─────────────┬─────────────┘
│
│ @Inject(DATABASE)
▼
┌───────────────────────────┐
│ Database Provider │
│ │
│ Drizzle + pg Pool │
└─────────────┬─────────────┘
│
▼
┌───────────────────────────┐
│ PostgreSQL │
└───────────────────────────┘

31. Next Steps​

The next stage is to complete the first end-to-end feature.

Recommended order:

1. Verify db:migrate
↓
2. Create UsersService
↓
3. Inject Database
↓
4. GET /users
↓
5. POST /users
↓
6. GET /users/:id
↓
7. UPDATE /users/:id
↓
8. DELETE /users/:id

After that, the database layer can be expanded with:

  • Foreign keys
  • Relations
  • One-to-many relationships
  • Many-to-many relationships
  • Transactions
  • Pagination
  • Filtering
  • Sorting
  • Database indexes
  • Unique constraints
  • Soft deletion
  • Timestamps
  • Authentication/user tables
  • Seeds
  • Testing
  • Production connection management

The important part is that the foundation is now in place:

NestJS
+
Drizzle ORM
+
Drizzle Kit
+
PostgreSQL

and there is no existing TypeORM migration history to work around.