By DatabaseJuly 16, 2026·12 min read

NestJS + Sequelize: Connecting Your API to PostgreSQL

Connect NestJS to PostgreSQL with Sequelize: models, migrations, relations, validators, hooks and transactions, plus the settings that bite in production.

To use Sequelize with NestJS, install @nestjs/sequelize with sequelize, sequelize-typescript and the pg driver, register SequelizeModule.forRoot() once in AppModule, list each model with SequelizeModule.forFeature() in its own module, and inject it with @InjectModel(). This post does that against PostgreSQL, then covers models, migrations with sequelize-cli, associations, transactions, and the one setting (synchronize) that changes your schema at startup without asking. It also builds the User model that the auth post in this series logs people into.

The four building blocks

A Model is a TypeScript class representing one table, decorated with @Table, with each field decorated with @Column. A Migration is a versioned file describing a schema change, like "add a table" or "add a column," which is how a team keeps every environment's database structure in sync and in history. An Association is a defined relationship between two models: hasMany, belongsTo, belongsToMany. And the Sequelize instance itself is the actual database connection, and the entry point for transactions and raw queries when the ORM isn't the right tool for a specific query. This post uses Sequelize v6 with sequelize-typescript for the decorators. Sequelize v7 is still in alpha and ships its own decorators, so the imports differ there.

Wiring it into NestJS

@nestjs/sequelize gives NestJS first-class support: SequelizeModule.forRoot({...}) in your root AppModule sets up the connection once (host, credentials, dialect: 'postgres'), and each feature module calls SequelizeModule.forFeature([User]) to register just the models it needs, which then become injectable via @InjectModel(User). One setting matters more than the rest: synchronize should be false outside of quick local prototyping. It's on by default whenever autoLoadModels: true is set, and it creates any missing tables at startup. On its own it never changes a table that already exists, so a model change quietly never reaches the database and you find out from errors at runtime. Add sync: { alter: true } and it rewrites tables to match your models, which can drop a column you still needed. sync: { force: true } drops and recreates every table. Migrations are the production-safe alternative: each schema change is an explicit, reviewable, versioned file.

bash
npm install @nestjs/sequelize sequelize sequelize-typescript pg pg-hstore
npm install --save-dev sequelize-cli
app.module.ts + users.module.ts
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';

@Module({
  imports: [
    SequelizeModule.forRoot({
      dialect: 'postgres',
      host: process.env.DB_HOST ?? 'localhost',
      port: Number(process.env.DB_PORT ?? 5432),
      username: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME,
      autoLoadModels: true, // pick up every model registered with forFeature
      synchronize: false, // schema changes go through migrations
    }),
    UsersModule,
  ],
})
export class AppModule {}

// users.module.ts
@Module({
  imports: [SequelizeModule.forFeature([User])],
  providers: [UsersService],
  exports: [UsersService], // AuthModule imports UsersModule to use it
})
export class UsersModule {}

The Nest Sequelize docs show synchronize: true next to autoLoadModels because it's the quickest way to see a table appear. Once a migration exists for your schema, set it to false as above. The rest of this post assumes it is.

Defining a model

A model class mirrors what you'd write in raw SQL, just declaratively: @AllowNull(false) @Column(DataType.STRING(100)) full_name maps to a NOT NULL VARCHAR(100) column, @Unique @Index @Column(DataType.STRING(255)) email maps to a unique, indexed VARCHAR(255), and @Default(true) @Column(DataType.BOOLEAN) is_active gives you a boolean with a default. @CreatedAt and @UpdatedAt automatically manage timestamp columns without you touching them manually on every write.

user.model.ts
import {
  Table,
  Column,
  Model,
  DataType,
  AllowNull,
  Unique,
  Index,
  Default,
  CreatedAt,
  UpdatedAt,
  DefaultScope,
} from 'sequelize-typescript';

// Leave the password hash out of every query unless a method asks for it
@DefaultScope(() => ({ attributes: { exclude: ['password'] } }))
@Table({ tableName: 'users', timestamps: true })
export class User extends Model {
  @AllowNull(false)
  @Column(DataType.STRING(100))
  full_name: string;

  @Unique
  @Index
  @AllowNull(false)
  @Column(DataType.STRING(255))
  email: string;

  // bcrypt hash, written once by AuthService. Never the plain password.
  @AllowNull(false)
  @Column(DataType.STRING(255))
  password: string;

  @Default(true)
  @Column(DataType.BOOLEAN)
  is_active: boolean;

  @AllowNull(false)
  @Default(0)
  @Column(DataType.INTEGER)
  credits: number;

  @CreatedAt
  created_at: Date;

  @UpdatedAt
  updated_at: Date;
}

Two columns need a word. password holds the bcrypt hash, and the @DefaultScope on the class keeps it out of every find call, so a controller that returns a user by mistake can't leak it. Per the scopes docs, unscoped() removes the default scope, and that's how the one query that needs the hash gets it. The scope only filters reads: the instance create() returns still holds every value you passed in, hash included, so pick fields before you send a user back. credits gets @AllowNull(false) so the model matches the NOT NULL column in the migration below.

If every field comes back undefined

This is a common first wall with sequelize-typescript in a new Nest project. The insert works, the row is in the database, but user.full_name is undefined, and Sequelize logs a warning about public class fields. The Nest starter's tsconfig targets ES2023, and from ES2022 on TypeScript emits each declared property as a real class field. Those fields shadow the getters and setters Sequelize puts on the model. There are two fixes: write declare full_name: string; for every attribute, which emits no field, or turn the behavior off once in tsconfig, which keeps model files as they are in this post. I reproduced the bug and checked both fixes with sequelize 6.37 and sequelize-typescript 2.1.

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2023",
    "useDefineForClassFields": false
  }
}

With the model in place, a small service is the only code that touches it. The NestJS JWT auth post calls exactly these three methods.

users.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { User } from './user.model';

@Injectable()
export class UsersService {
  constructor(@InjectModel(User) private readonly userModel: typeof User) {}

  create(data: { full_name: string; email: string; password: string }) {
    return this.userModel.create(data);
  }

  // Default scope applies: no password hash in the result
  findById(id: number) {
    return this.userModel.findByPk(id);
  }

  // Login is the one caller that needs the hash, so it asks explicitly
  findByEmailWithPassword(email: string) {
    return this.userModel.unscoped().findOne({ where: { email } });
  }
}

Sequelize migrations with sequelize-cli

sequelize-cli migration:generate --name create-users scaffolds a migration file with two functions: up (what to do: create the table, add the column) and down (how to undo it). Run npx sequelize-cli db:migrate to apply every pending migration, or db:migrate:undo to roll back the last one. This matters because it makes schema changes reviewable in a pull request, reproducible across every developer's machine, and reversible if something goes wrong. None of that is true if everyone's local database just "synchronizes" from whatever their models currently look like.

migrations/20260101-create-users.js
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('users', {
      id: {
        type: Sequelize.INTEGER,
        primaryKey: true,
        autoIncrement: true,
      },
      full_name: {
        type: Sequelize.STRING(100),
        allowNull: false,
      },
      email: {
        type: Sequelize.STRING(255),
        allowNull: false,
        unique: true,
      },
      password: {
        type: Sequelize.STRING(255),
        allowNull: false,
      },
      is_active: {
        type: Sequelize.BOOLEAN,
        defaultValue: true,
      },
      credits: {
        type: Sequelize.INTEGER,
        allowNull: false,
        defaultValue: 0,
      },
      created_at: {
        type: Sequelize.DATE,
        allowNull: false,
      },
      updated_at: {
        type: Sequelize.DATE,
        allowNull: false,
      },
    });
  },

  down: async (queryInterface) => {
    await queryInterface.dropTable('users');
  },
};

The day-to-day workflow

The CLI reads its own config, separate from SequelizeModule.forRoot. In a Nest project, skip plain init (it also scaffolds a models folder you won't use) and point the CLI at a JavaScript config that reads the same environment variables as the app. It records every migration it has run in a SequelizeMeta table, which is how db:migrate knows what's pending. The migrations guide has every option.

.sequelizerc + config/database.js
// .sequelizerc (project root)
const path = require('path');

module.exports = {
  config: path.resolve('config', 'database.js'),
  'migrations-path': path.resolve('migrations'),
};

// config/database.js: one block per NODE_ENV (development by default)
module.exports = {
  development: {
    username: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_NAME,
    host: process.env.DB_HOST ?? 'localhost',
    dialect: 'postgres',
  },
  production: {
    username: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_NAME,
    host: process.env.DB_HOST,
    dialect: 'postgres',
  },
};
bash
npx sequelize-cli migration:generate --name add-last-login-to-users
npx sequelize-cli db:migrate          # run every pending migration
npx sequelize-cli db:migrate:status   # see which ran and which are pending
npx sequelize-cli db:migrate:undo     # roll back the most recent one

Changing a table that already has data

Most migrations after the first one alter an existing table. Write the down so it really undoes the up, then add the matching @Column to the model in the same pull request.

migrations/20260301120000-add-last-login-to-users.js
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.addColumn('users', 'last_login_at', {
      type: Sequelize.DATE,
      allowNull: true, // existing rows have no value yet
    });
  },

  down: async (queryInterface) => {
    await queryInterface.removeColumn('users', 'last_login_at');
  },
};
  • –A new NOT NULL column on a table with rows fails unless you give it a defaultValue. The other route is three steps: add it nullable, backfill it, then tighten it with changeColumn.
  • –Never edit a migration that has already run anywhere. SequelizeMeta stores only file names, so the change never runs there. Write a new migration instead.
  • –Run db:migrate as a deploy step before the new app version starts, so the code never queries a column that isn't there yet.

Sequelize associations: hasOne, hasMany and belongsToMany

Sequelize expresses table relationships as decorator pairs on both sides. A one-to-many (a user has many posts) is @HasMany(() => Post) on User paired with @ForeignKey(() => User) @BelongsTo(() => User) on Post. A one-to-one (a user has one profile) uses @HasOne instead of @HasMany. A many-to-many (users and roles) uses @BelongsToMany(() => Role, () => UserRole) on User, with the mirror image, @BelongsToMany(() => User, () => UserRole), on Role. Both point at an explicit join table model that just holds the two foreign keys. Once defined, fetching a user with their posts is one call, User.findByPk(1, { include: [{ model: Post }] }), and Sequelize generates the JOIN for you.

user.model.ts + post.model.ts
// user.model.ts
@Table
export class User extends Model {
  @HasMany(() => Post)
  posts: Post[];
}

// post.model.ts
@Table
export class Post extends Model {
  @ForeignKey(() => User)
  @Column(DataType.INTEGER)
  user_id: number;

  @BelongsTo(() => User)
  author: User;
}

// fetching a user with their posts in one call
const user = await User.findByPk(1, {
  include: [{ model: Post }],
});

The User model has @HasMany Post and the Post model has @ForeignKey and @BelongsTo User; together they produce posts.user_id referencing users.id. User.findByPk with include Post runs a SELECT from users with a LEFT JOIN on posts.

How Sequelize associations become SQL

Many-to-many with a through model

A belongsToMany needs a join table with one row per pair. Giving it its own model, the through model, means you control its table name and columns, and can add more later, like when a role was granted. Both sides point at it, and the associations guide explains why Sequelize wants associations defined in pairs.

role.model.ts + user-role.model.ts
// role.model.ts
@Table({ tableName: 'roles', timestamps: false })
export class Role extends Model {
  @Unique
  @AllowNull(false)
  @Column(DataType.STRING(50))
  name: string;

  @BelongsToMany(() => User, () => UserRole)
  users: User[];
}

// user-role.model.ts: the join table, one row per (user, role) pair
@Table({ tableName: 'user_roles', timestamps: false })
export class UserRole extends Model {
  @ForeignKey(() => User)
  @Column(DataType.INTEGER)
  user_id: number;

  @ForeignKey(() => Role)
  @Column(DataType.INTEGER)
  role_id: number;
}

// user.model.ts gets the other side:
//   @BelongsToMany(() => Role, () => UserRole)
//   roles: Role[];

// Grant a role, then load a user with roles but without join-table columns
await user.$add('roles', adminRole);
const withRoles = await User.findByPk(user.id, {
  include: [{ model: Role, through: { attributes: [] } }],
});

Without through: { attributes: [] }, every role in the result carries a nested UserRole object. In the migration for user_roles, add a unique index on (user_id, role_id) so the same role can't be granted twice.

The N+1 query trap

Associations make one bad pattern easy to write: load 50 posts, then call post.$get('author') inside a loop. That's 1 query for the posts plus 50 for the authors. Put the association in include instead, and Sequelize fetches everything with a JOIN in one query. Turn on logging: console.log in development and count the queries a single request runs. It catches this faster than any profiler.

Querying: conditions, sorting, pagination

Sequelize's Op object covers SQL comparison operators without writing raw SQL: { age: { [Op.gte]: 18 } } becomes age >= 18, { email: { [Op.iLike]: '%@gmail.com' } } becomes a case-insensitive pattern match. For pagination, findAndCountAll({ limit, offset }) is the method to reach for. It returns the page of rows and the total count from one call. Under the hood it still runs two queries, a COUNT and a SELECT, so it saves you code, not a round trip. If you add include, pass distinct: true, or the joined rows inflate the count. And when a query genuinely gets too complex for the ORM's query builder to express cleanly, sequelize.query() with QueryTypes.SELECT lets you drop down to raw SQL without leaving the same service. There's no shame in that when it's the clearer option.

users.service.ts
import { Op, QueryTypes } from 'sequelize';

// active Gmail users, newest first, 20 per page
const { rows, count } = await this.userModel.findAndCountAll({
  where: {
    is_active: true,
    email: { [Op.iLike]: '%@gmail.com' },
  },
  attributes: ['id', 'full_name', 'email'],
  order: [['created_at', 'DESC']],
  limit: 20,
  offset: 0,
});

// raw SQL when the query builder gets in the way
const recent = await this.sequelize.query(
  'SELECT full_name FROM users WHERE created_at > :since',
  { replacements: { since: '2026-01-01' }, type: QueryTypes.SELECT },
);

Two layers of protection: constraints and validators

Constraints (UNIQUE, NOT NULL) are enforced by PostgreSQL itself: the database physically refuses a bad write. Validators run one layer earlier, inside Node, before any SQL is even generated: validate: { isEmail: true, len: [5, 255] } on a column rejects bad input before it gets anywhere near the database. Validators run first; if they fail, no query is sent at all. One exception: bulkCreate skips validators unless you pass validate: true. This two-layer setup means even if something bypasses your application code entirely, the database constraint is still there as a last line of defense.

Hooks: code that runs automatically around a write

A hook is a function Sequelize calls automatically at a specific point in a record's lifecycle. @BeforeCreate and @BeforeUpdate on a static method run just before a record is inserted or saved, which makes them a good place to tidy data, like trimming stray spaces from a name. Plenty of tutorials hash passwords in a hook too. This series doesn't: the auth post hashes in AuthService, and hashing in both places stores a hash of a hash that no password will ever match. Pick one place. Hooks also don't cover every code path. A static User.update(values, { where }) fires beforeBulkUpdate, not beforeUpdate, unless you pass individualHooks: true. bulkCreate works the same way. A password hashed in a hook would be stored as plain text on those paths. Other hooks like @AfterCreate (trigger a welcome email) or @BeforeDestroy (write an audit log) let you attach side effects to the data layer itself instead of scattering them across every service that touches that model.

user.model.ts
// inside the User model
@BeforeCreate
@BeforeUpdate
static trimName(user: User) {
  // only touch the field when it actually changed
  if (user.changed('full_name')) {
    user.full_name = user.full_name.trim();
  }
}

beforeValidate, validate, afterValidate, then beforeCreate (for example trimming the name), then the INSERT runs, then afterCreate (for example sending a welcome email).

The order Sequelize runs hooks on create

Transactions in Sequelize

The managed pattern is the one to default to: this.sequelize.transaction(async (t) => { ... }) automatically commits if the callback finishes successfully, and automatically rolls back if anything inside throws. You never call commit or rollback yourself. Every query inside needs { transaction: t } passed explicitly so Sequelize knows it belongs to that unit of work. This is essential for anything touching multiple rows or tables that must succeed or fail together, like transferring credits between two user accounts.

credits.service.ts
async transferCredits(fromId: number, toId: number, amount: number) {
  if (!Number.isInteger(amount) || amount <= 0) {
    throw new BadRequestException('Amount must be a positive whole number');
  }
  if (fromId === toId) {
    throw new BadRequestException('Cannot transfer to the same user');
  }

  return this.sequelize.transaction(async (t) => {
    // Lock both rows in one query, always in id order. If A-to-B and
    // B-to-A each locked their own sender first, they would wait on
    // each other and Postgres would abort one as a deadlock.
    const users = await this.userModel.findAll({
      where: { id: [fromId, toId] },
      order: [['id', 'ASC']],
      transaction: t,
      lock: t.LOCK.UPDATE,
    });
    const sender = users.find((u) => u.id === fromId);
    const receiver = users.find((u) => u.id === toId);

    if (!sender || !receiver) {
      throw new NotFoundException('User not found');
    }

    if (sender.credits < amount) {
      throw new BadRequestException('Insufficient credits');
    }

    sender.credits -= amount;
    receiver.credits += amount;

    await sender.save({ transaction: t });
    await receiver.save({ transaction: t });
    // if any line above throws, everything rolls back automatically
  });
}

Inject the connection with constructor(private readonly sequelize: Sequelize), importing Sequelize from sequelize-typescript. The other style is unmanaged: you get a transaction object and call commit or rollback yourself. It's useful when the transaction spans code that can't sit inside one callback, but forgetting the rollback leaves a connection stuck in an open transaction, so prefer the managed form. Either one takes an isolation level. Without one, PostgreSQL uses its default, READ COMMITTED. The transactions docs also show how to pass the transaction to every query automatically with cls-hooked, instead of adding { transaction: t } by hand.

users.service.ts
import { Transaction } from 'sequelize';

// Unmanaged: you call commit and rollback yourself
async deactivate(userId: number) {
  const t = await this.sequelize.transaction({
    isolationLevel: Transaction.ISOLATION_LEVELS.REPEATABLE_READ,
  });
  try {
    await this.userModel.update(
      { is_active: false },
      { where: { id: userId }, transaction: t },
    );
    await t.commit();
  } catch (err) {
    await t.rollback();
    throw err;
  }
}

Turning Sequelize errors into clean API responses

Sequelize throws specific, typed errors: UniqueConstraintError when a unique field is duplicated, ValidationError when a validator fails, ForeignKeyConstraintError when a referenced row doesn't exist. A dedicated NestJS exception filter with @Catch(ValidationError, UniqueConstraintError, ForeignKeyConstraintError) can translate each into the right HTTP status (409 Conflict for duplicates, 422 for validation failures, 400 for a bad foreign key), so your controllers never need a try/catch just to handle database errors gracefully. Inside the filter, check UniqueConstraintError before ValidationError. In Sequelize the first extends the second, so checking ValidationError first turns every duplicate into a 422. And import ValidationError from 'sequelize' explicitly, since class-validator exports a class with the same name.

Two features worth knowing about: soft deletes and scopes

paranoid: true on a model turns destroy() into setting a deleted_at timestamp instead of running a real DELETE, and every query automatically excludes soft-deleted rows from then on. It's useful when you need an undo path or an audit trail rather than permanent deletion. Scopes let you name a reusable query filter on the model itself, like active: { where: { is_active: true } }, so User.scope('active').findAll() reads clearly everywhere it's used instead of repeating the same where clause across a dozen services.

A performance checklist

  • –Index every foreign key column yourself. Sequelize doesn't add these automatically.
  • –Use attributes: [...] to select only the columns you actually need.
  • –Use findAndCountAll for pagination. It still runs a COUNT and a SELECT, but it's one call in your code.
  • –Wrap multi-step writes in a managed transaction to prevent partial updates.
  • –Always synchronize: false in production. Migrations only.

Sequelize vs TypeORM vs Prisma in NestJS

All three work with Nest and PostgreSQL. The differences that matter day to day, per the Nest data docs and each project's own docs:

  • –Sequelize: model classes with decorators through sequelize-typescript, an official @nestjs/sequelize package, and hand-written JavaScript migrations run by a separate CLI. v6 is the stable line. v7 is still in alpha.
  • –TypeORM: also decorators (@Entity, @Column) and an official @nestjs/typeorm package. You work through repositories, and its CLI can generate a migration by diffing your entities against the database.
  • –Prisma: you describe tables in a schema.prisma file and it generates a typed client and SQL migrations from it. There's no Nest-specific package. You expose the client through your own provider.

If a codebase already runs on Sequelize, which plenty of Node backends do, learning it properly beats rewriting. On a new project, Prisma's generated types and TypeORM's migration generation are real reasons to look at them. None of them saves you from knowing the SQL they produce.

Common questions

Is Sequelize still maintained?

Yes. v6 is the stable release line and still gets maintenance releases (6.37.8 is the latest on npm as of October 2026). v7, published as @sequelize/core, is still alpha, so production code should stay on v6 for now.

Should I use synchronize in NestJS with Sequelize?

Only for a throwaway prototype. It creates missing tables but never updates existing ones, and the alter and force options can drop columns or whole tables. Use migrations for anything with data you care about.

Why are my Sequelize model properties undefined?

Almost always class fields shadowing Sequelize's getters, because your tsconfig targets ES2022 or later. Set useDefineForClassFields to false, or declare each attribute with declare.

How do I run Sequelize migrations in production?

Run npx sequelize-cli db:migrate with NODE_ENV=production as a release step, before the new version of the app starts taking traffic. The CLI picks the production block from your config and runs only what SequelizeMeta says is pending.

Sequelize doesn't hide SQL from you, and it shouldn't. You still want to know what query each method generates, which is why learning raw PostgreSQL first pays off. Next in the series: NestJS + React auth, where this User model gets signup, login and JWTs. For where this fits in the bigger picture, see the backend developer roadmap for this series. I'm a frontend engineer who builds the UI on top of APIs like this one. If you need that, see my work or get in touch.