Implement Sequelize Paranoid Soft Delete with Restore and Force Delete
Set up Sequelize paranoid soft delete so destroy marks deletedAt, normal queries hide rows, and restore or force delete remain available. Includes model setup, verification steps and recovery options.
20 Feb 2026, 00:10 UTC

Problem: you need deletes that can be undone without losing audit history
Hard deletes remove data permanently and break referential history. Sequelize paranoid mode implements soft delete by writing a timestamp to deletedAt instead of removing the row. Normal queries automatically exclude those rows, while restore and force delete remain available.
Desired outcome
A model where Model.destroy marks deletedAt, normal finds hide the row, Model.findAll with paranoid:false reveals it, Model.restore clears deletedAt, and Model.destroy with force:true removes the row physically.
Prerequisites
A Node.js project with Sequelize configured for your dialect. Database user must have ALTER, INSERT, UPDATE, DELETE on the target schema. Existing model file and a working sequelize.sync or migration pipeline.
Paranoid is Sequelize's name for soft delete. deletedAt is a nullable timestamp column Sequelize uses to filter rows.
Model definition with paranoid
Define the model with paranoid:true. Sequelize will add deletedAt if the table is created via sync. For production, define the column explicitly in a migration to control nullability and indexes.
// models/user.js
const { DataTypes } = require('sequelize')
module.exports = (sequelize) => {
sequelize.define('User', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
email: { type: DataTypes.STRING, allowNull: false },
deletedAt: { type: DataTypes.DATE, allowNull: true }
}, {
paranoid: true,
tableName: 'users'
})
}
Run sync or migration in an environment with write access. Verify the column exists by inspecting the table schema.
Soft delete operation
Call destroy on the model. With paranoid enabled Sequelize issues an UPDATE setting deletedAt to current time.
await User.destroy({ where: { id: 1 } })
Expected effect: row remains in table, deletedAt is populated, subsequent User.findOne({ where:{ id:1 }}) returns null.
Risk: raw SQL queries bypass paranoid filtering. Any raw query must include deletedAt IS NULL where appropriate.
Reading soft-deleted rows
Override the filter with paranoid:false.
const hidden = await User.findOne({ where: { id: 1 }, paranoid: false })
Expected check: hidden is returned and hidden.deletedAt is non-null. Normal queries without paranoid:false should not return it.
Restoring a record
Restore clears deletedAt.
await User.restore({ where: { id: 1 } })
Expected check: row is visible again in normal finds and deletedAt is null.
Force hard delete
Use force:true to bypass paranoid and physically remove the row.
await User.destroy({ where: { id: 1 }, force: true })
Expected check: row no longer exists even with paranoid:false. This operation is irreversible.
Verification checklist
- Table contains deletedAt column nullable.
- After destroy, direct table inspection shows deletedAt populated.
- Normal find returns null, paranoid:false find returns record.
- After restore, deletedAt is null and record appears normally.
- After force destroy, record absent in both modes.
Limitations and recovery options
Associations inherit paranoid filtering. Eager loaded parents are excluded unless paranoid:false is set on the association. Soft-deleted rows still consume storage and can conflict with unique constraints unless partial unique indexes are used.
Recovery options: restore for accidental soft delete, force:true for permanent removal when retention policy requires it. Add an index on deletedAt for performance when filtering large tables.
For bulk operations keep paranoid:true to maintain consistent filtering. If you perform updates via raw SQL, ensure deletedAt is respected to avoid resurrecting or hiding rows unintentionally.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.