How to Validate Data Before Saving It to the Database
Where to validate data before saving — front, back, and DB constraints — and how to do it safely without scattering ifs.
An email field that accepts john@@gmail, a phone number saved with one digit missing, a negative price that zeroes out your month-end totals. Almost every dirty-data bug starts the same way: someone trusted that the data would arrive clean and saved it straight to the database. The trouble is that once it's persisted, an invalid value stops being a form bug and becomes a business problem — a wrong report, a broken integration, an angry customer. Validating before you save is the last point where you still control the damage.
This guide covers where to validate, what each layer solves (and what it doesn't), and how to build validation that holds up against both the distracted user and the malicious client — without scattering if statements everywhere.
Validation is not a single layer
The most common mistake is treating validation as one decision: "do I validate on the front or the back?". The honest answer is: both, plus the database, each with a different job.
- Front-end (browser): instant feedback. Show the error before the user submits, no round-trip. This is UX, not security. Anyone can open DevTools and bypass it.
- Back-end (application): the real boundary. This is where you decide whether the data enters the system. Everything arriving over HTTP is hostile until proven otherwise — including requests that never touched your form.
- Database: the final lock. Constraints (
NOT NULL,UNIQUE,CHECK, foreign keys) enforce invariants even if an application bug slips through, or another service writes to the same table.
The golden rule: never trust validation you don't control at write time. The front validates for the user; the back validates for the system; the database validates for the truth.
Start at the application: validate at the edge
The first thing to do is validate input the moment it arrives, before any business logic. In PHP/Laravel this is declarative:
$data = $request->validate([
'email' => 'required|email|max:255',
'age' => 'required|integer|min:18|max:120',
'price' => 'required|numeric|min:0',
]);
In Node, the same idea with Zod — with the bonus that the type comes for free:
import { z } from "zod";
const User = z.object({
email: z.string().email().max(255),
age: z.number().int().min(18).max(120),
price: z.number().nonnegative(),
});
const result = User.safeParse(req.body);
if (!result.success) return res.status(422).json(result.error.flatten());
The key point: validate and transform in the same step. " john@gmail.com " should become john@gmail.com (trim + lowercase) before it's saved. Validation without normalization produces duplicates that sneak past your UNIQUE constraint thanks to an invisible space.
Distinguish the three kinds of rule
Lumping everything into one validator is what makes the code unreadable. Separate them mentally:
- Format/syntax: does the data have the right shape? Email with an
@, a parseable date, a number within range. Solved with a schema (Zod, Laravel rules, JSON Schema). - Domain rule: does the data make business sense? End date after start date, order total equal to the sum of line items. This is code, not schema.
- State/consistency rule: is the data unique right now? Email already registered, stock available, slug free. Only the database knows — and only reliably inside a transaction.
The classic mistake is solving point 3 with a SELECT before the INSERT:
// Fragile: race condition between SELECT and INSERT
const exists = await db.user.findUnique({ where: { email } });
if (exists) throw new Error("Email already registered");
await db.user.create({ data: { email } });
Between findUnique and create, two concurrent requests pass the same if. The correct defense is a UNIQUE constraint in the database and catching the violation:
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);
try {
await db.user.create({ data: { email } });
} catch (e) {
if (e.code === "P2002") return res.status(409).json({ error: "Email already exists" });
throw e;
}
The database is the only arbiter of uniqueness that has no race condition.
Use the database as a safety net, not the primary validator
Constraints are excellent for simple, cheap invariants: CHECK (price >= 0), NOT NULL, foreign keys, UNIQUE. They protect against bugs and against other applications writing to the same store. But they have limits: the error message is technical, validation happens late (after building the query), and complex rules become triggers that are painful to maintain.
The split that works in practice:
- Application: rich validation, friendly messages, domain rules.
- Database: invariants that can never be violated, no matter what.
The two overlap on purpose. That's intentional redundancy, not waste.
End-to-end validation has more than one gate
It's worth noting that validating form input is just one gate. Data arriving via bulk import needs to pass the same validation — and there you hit problems a form never has, like broken encoding. If you process spreadsheet uploads, it's worth reading how to import CSV without corrupting characters, because an accent mangled into ç passes any "non-empty string" check and only shows up as dirty data much later.
The idea is to treat every input boundary — form, API, queue, import — with the same rigor. A reusable schema helps: define the contract once and apply it at every entry point.
Schema as a shared contract
When the front, back, and integrations all need to agree on what valid data looks like, writing the rule three times guarantees they'll drift apart. The way out is to declare the structure in a neutral format — JSON Schema — and validate against it in any language:
{
"type": "object",
"required": ["email", "age"],
"properties": {
"email": { "type": "string", "format": "email" },
"age": { "type": "integer", "minimum": 18, "maximum": 120 }
},
"additionalProperties": false
}
The additionalProperties: false is underrated: it rejects fields you didn't expect, which blocks mass assignment (someone slipping "is_admin": true into the payload). Before locking a schema like this into your code, it's worth testing it against real payloads with the JSON Schema validator to confirm it accepts what it should and rejects what it shouldn't.
Frequently asked questions
Do I need to validate on both the front and the back, or is one enough?
Both, but for different reasons. The front improves the experience (instant error, no reload); the back guarantees safety. Validating only on the front is a security hole — any HTTP client ignores your JavaScript. Validating only on the back is a poor experience, but a safe one. In serious production, do both, plus database constraints.
How do I validate a username and password stored in the database?
On read (login), you don't validate format — you compare. Never store a password in plain text: save the hash (bcrypt or Argon2) and, at login, compare the typed password against the hash via password_verify (PHP) or bcrypt.compare (Node). Password format validation (minimum length, strength) happens at signup, before hashing; the verification at login is a cryptographic comparison, not a format check.
Is validating in the database with CHECK constraints good practice?
Yes, for simple, non-negotiable invariants (price >= 0, quantity > 0). It's the only layer that protects against application bugs and against other services writing to the same table. Don't use the database for complex business rules — the error message is poor and maintenance via triggers is painful. Database = safety net; application = rich validation.
What should I do when validation fails during a large import?
Don't stop everything on the first error. Validate each row, accumulate errors with row number and field, and return a report. For valid records, decide the policy: skip the bad rows and import the rest, or reject the whole batch in a transaction. Both are legitimate — what's wrong is importing half of it and leaving the user unsure of what failed.
What to take from this guide
Validation isn't an if in the controller; it's a layered strategy. Validate format at the application edge, with normalization alongside. Treat domain rules as code separate from the schema. Leave uniqueness and invariants to the database, inside transactions, and catch the violations instead of trying to predict them with a SELECT. And when the contract needs to be shared across services, declare it once as a schema and validate against it at every entry point. Clean data in the database is the result of trusting no one at write time — not the user, not even your own application.
- 01 UTF-8 and encoding problems: why accents break and how to fix them What UTF-8 is, code points, mojibake, and why accented text turns to garbage — the mechanism explained end to end.
- 02 MIT, Apache, and GPL Licenses: Practical Differences for Developers MIT, Apache 2.0, and GPL are not the same thing. Understand permissive vs copyleft, patent protection, and compatibility before shipping code to production.