Skip to content
Express.js ex data 5 min read

Sequelize ORM

Sequelize is a mature, promise-based Object-Relational Mapper (ORM) for Node.js that works with PostgreSQL, MySQL, MariaDB, SQLite, and SQL Server. Instead of writing raw SQL in your Express route handlers, you describe your tables as models, declare the relationships between them, and query through a clean async/await API. For teams that want typed models, validation, transactions, and a structured migration workflow on top of a relational database, Sequelize is one of the most battle-tested choices in the ecosystem.

Installing and connecting

Install Sequelize along with the driver for your database. Each dialect ships its own native client, so pick the one that matches your engine.

npm install express sequelize
npm install pg pg-hstore        # PostgreSQL
# npm install mysql2            # MySQL / MariaDB
# npm install sqlite3           # SQLite

Create a single Sequelize instance at startup. The instance manages an internal connection pool, so you create it once and reuse it across every request — never per route.

// db.js
const { Sequelize } = require('sequelize');

const sequelize = new Sequelize(process.env.DATABASE_URL, {
  dialect: 'postgres',
  logging: false,          // set to console.log to see generated SQL
  pool: { max: 10, min: 0, idle: 10000 },
});

async function connectDB() {
  await sequelize.authenticate(); // verifies the connection works
  console.log('Database connected');
}

module.exports = { sequelize, connectDB };

Call connectDB before you start listening, and exit if it fails so the server never serves traffic against a dead database.

// server.js
const express = require('express');
const { connectDB } = require('./db');

const app = express();
app.use(express.json()); // parse JSON request bodies

connectDB()
  .then(() => app.listen(3000, () => console.log('Listening on :3000')))
  .catch((err) => {
    console.error('DB connection failed', err);
    process.exit(1);
  });

Tip: Keep the connection string in an environment variable. A typical PostgreSQL URL looks like postgres://user:pass@localhost:5432/mydb.

Defining models

A model maps to a database table. Each attribute becomes a column with a type, optional default, and validation rules. Sequelize adds id, createdAt, and updatedAt columns automatically.

// models/User.js
const { DataTypes } = require('sequelize');
const { sequelize } = require('../db');

const User = sequelize.define('User', {
  name: {
    type: DataTypes.STRING,
    allowNull: false,
  },
  email: {
    type: DataTypes.STRING,
    allowNull: false,
    unique: true,
    validate: { isEmail: true }, // rejects malformed addresses
  },
  role: {
    type: DataTypes.ENUM('admin', 'member'),
    defaultValue: 'member',
  },
}, {
  tableName: 'users', // explicit, instead of the auto-pluralized name
});

module.exports = User;

Defining associations

Associations describe how tables relate. Declaring them tells Sequelize how to generate join queries and eager-load related rows. The four core helpers are hasOne, hasMany, belongsTo, and belongsToMany.

// models/Post.js
const { DataTypes } = require('sequelize');
const { sequelize } = require('../db');
const User = require('./User');

const Post = sequelize.define('Post', {
  title: { type: DataTypes.STRING, allowNull: false },
  body: DataTypes.TEXT,
});

// A user writes many posts; each post belongs to one user.
User.hasMany(Post, { foreignKey: 'authorId', as: 'posts' });
Post.belongsTo(User, { foreignKey: 'authorId', as: 'author' });

module.exports = Post;
HelperRelationshipForeign key lives on
hasOneone-to-onetarget table
belongsToone-to-one / many-to-onesource table
hasManyone-to-manytarget table
belongsToManymany-to-manyjoin table

Querying in route handlers

With models in place, route handlers read like plain object operations. Wrap each handler in try/catch (or an async error wrapper) so rejected queries reach Express’s error handler rather than crashing the process.

// routes/users.js
const express = require('express');
const User = require('../models/User');
const Post = require('../models/Post');

const router = express.Router();

// Create
router.post('/', async (req, res, next) => {
  try {
    const user = await User.create(req.body); // validates before insert
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
});

// Read one, eager-loading the user's posts via the association
router.get('/:id', async (req, res, next) => {
  try {
    const user = await User.findByPk(req.params.id, {
      include: { model: Post, as: 'posts' },
    });
    if (!user) return res.status(404).json({ error: 'Not found' });
    res.json(user);
  } catch (err) {
    next(err);
  }
});

// List with a filter
router.get('/', async (req, res, next) => {
  try {
    const users = await User.findAll({
      where: { role: 'member' },
      order: [['createdAt', 'DESC']],
      limit: 20,
    });
    res.json(users);
  } catch (err) {
    next(err);
  }
});

module.exports = router;

A GET /users/1 against a seeded database returns the user with nested posts:

Output:

{
  "id": 1,
  "name": "Ada Lovelace",
  "email": "[email protected]",
  "role": "member",
  "createdAt": "2026-06-14T09:12:00.000Z",
  "updatedAt": "2026-06-14T09:12:00.000Z",
  "posts": [
    { "id": 4, "title": "On analytical engines", "authorId": 1 }
  ]
}

Warning: In Express 5, errors thrown in an async handler are forwarded to the error middleware automatically, so next(err) is optional. In Express 4 you must call next(err) yourself (or use a wrapper like express-async-errors), or the request will hang.

Managing migrations

In development you can call sequelize.sync() to create or alter tables from your models, but in production you should use migrations — versioned, reviewable scripts that evolve the schema deterministically. The Sequelize CLI manages them.

npm install --save-dev sequelize-cli
npx sequelize-cli init                          # creates migrations/ and config/
npx sequelize-cli migration:generate --name add-users

A migration exports up (apply) and down (revert) functions:

// migrations/20260614-add-users.js
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable('users', {
      id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
      name: { type: Sequelize.STRING, allowNull: false },
      email: { type: Sequelize.STRING, allowNull: false, unique: true },
      role: { type: Sequelize.ENUM('admin', 'member'), defaultValue: 'member' },
      createdAt: { type: Sequelize.DATE, allowNull: false },
      updatedAt: { type: Sequelize.DATE, allowNull: false },
    });
  },

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

Apply and roll back with the CLI:

npx sequelize-cli db:migrate          # run pending migrations
npx sequelize-cli db:migrate:undo     # revert the last one

Output:

== 20260614-add-users: migrating =======
== 20260614-add-users: migrated (0.041s)

Best Practices

  • Create one Sequelize instance for the whole process and tune the pool settings to match your database’s connection limit.
  • Use migrations — not sync({ force: true }) — for any environment with data you care about; force drops tables.
  • Define validate rules on attributes so bad data is rejected before it reaches SQL, and let Sequelize’s ValidationError surface a clean 400.
  • Eager-load with include to avoid N+1 query problems, and add attributes to fetch only the columns you need.
  • Wrap multi-step writes in sequelize.transaction() so partial failures roll back atomically.
  • Keep logging: false in production but enable it locally to inspect the SQL Sequelize generates.
Last updated June 14, 2026
Was this helpful?