Database constraints are the best validation layer. They are the last line of defense in any system, and they never get skipped. Frontend validation can be bypassed, and backend validation can contain bugs, but the datab…
Database constraints are the best validation layer. They are the last line of defense in any system, and they never get skipped. Frontend validation can be bypassed, and backend validation can contain bugs, but the database always enforces the rules. When bad data reaches the database and gets rejected, that is not a problem, but protection.
When building an API, it is common to rely heavily on application-level validation. We add DTOs, validation decorators, and checks in services. These are important, but they should not be the only guard. APIs are not used only by one frontend. They are called by scripts, background jobs, and sometimes by unexpected clients. Because of this, the database must be allowed to say “no” when something is wrong.
PostgreSQL provides powerful constraints that help keep data correct. NOT NULL prevents missing values, UNIQUE avoids duplicates, FOREIGN KEY keeps relationships valid, and CHECK rules enforce business limits. These constraints are simple, fast, and always applied. Once they are in place, every insert and update must follow the same rules.
ALTER TABLE users
ADD CONSTRAINT users_email_unique UNIQUE (email);
For those using Typescript ORMs (and most of us do), here is the same unique email constraint defined using popular tools. No matter the ORM, this still becomes a real database constraint.
Prisma
model User {
id String @id @default(uuid())
email String @unique
name String?
}
Drizzle
import { pgTable, uuid, text, uniqueIndex } from 'drizzle-orm/pg-core';
export const users = pgTable(
'users',
{
id: uuid('id').defaultRandom().primaryKey(),
email: text('email').notNull(),
},
(table) => ({
emailUnique: uniqueIndex('users_email_unique').on(table.email),
}),
);
TypeORM
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
}
Letting the database reject invalid data also simplifies backend code. You do not need to handle every edge case in advance. Instead, you can catch database errors and convert them into clean, user-friendly API responses. This keeps your business logic focused and your error handling consistent.
try {
await this.userRepository.save(user);
} catch (error) {
// the code depends on the ORM you are using, this is a postgres error code
if (error.code === '23505') {
throw new AppError('Email already exists', 'EMAIL_ALREADY_EXISTS', 409);
}
throw error;
}
A good API is not one that never fails, but one that fails safely. When the database is allowed to enforce the rules, your API becomes more reliable and easier to reason about. Letting the database say “no” protects your data, your system, and your future work.