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
These two concerns should not be confused.
drizzle.config.tsconfigures Drizzle Kit.- The NestJS
DatabaseModuleconfigures 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
| Package | Purpose |
|---|---|
drizzle-orm | Drizzle ORM used by the application |
pg | PostgreSQL driver for Node.js |
drizzle-kit | Drizzle CLI and migration tooling |
@types/pg | TypeScript definitions for pg |
3. Environment Configuration
Create a .env file in the project root:
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:
.env
A useful .env.example can be committed:
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:
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:
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:
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:
UsersModulePostsModuleAuthModuleTasksModuleCommentsModule
8. Register the Database Module
In 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:
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:
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.
NULLis 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
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:
export * from './users.schema';
As more tables are added:
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:
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
This distinction is important:
generatedoes not modify the database.migrateexecutes the generated migration against PostgreSQL.
17. Recommended Package Scripts
Add the following to 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
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:
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:
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:
export const tasks = pgTable('tasks', {
// columns...
});
Export it:
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_URLenvironment variable drizzle.config.ts- A NestJS
DatabaseModule - A NestJS Drizzle provider
- A typed
Databasetype - A schema directory
- A
userstable 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.