14 — Databases and ORMs — From Raw Drivers to Prisma
"Just connect and query" was my database mental model, and it hid the ladder underneath. The model that finally stuck is a ladder: from raw native drivers at the bottom (full control, full SQL), through query builders in the middle (SQL-shaped but JavaScript), to ORMs at the top (models, types, migrations, the most abstraction). Where I land on that ladder is a trade of how much SQL I write versus how much the tool generates for me [1].
The framing that landed for me is the ladder, with NoSQL sitting on its own branch.
What a database is, and the two families
A database is an organized store of structured data, controlled by a management system (DBMS) that handles how data is written, read, indexed, and constrained [2]. The two families that matter for Node.js work are:
- Relational (SQL) — PostgreSQL, MySQL, SQLite, SQL Server. Data lives in tables with rows and columns; relationships are expressed through foreign keys and joins; the schema is declared up front. The query language is SQL.
- NoSQL (document) — MongoDB and similar. Data lives as flexible documents (often JSON-shaped); the schema can be implicit or polymorphic; relationships are usually embedded rather than joined.
Node.js connects to both. The choice between families is a schema-and-query-shape question, not a Node question — but the libraries I reach for differ by family.
The bottom rung: native drivers
At the bottom of the ladder are native drivers — packages provided by the database vendors that speak the database's wire protocol directly [3][4]. For relational databases, that means pg for PostgreSQL, mysql2 for MySQL. For NoSQL, the MongoDB driver. A native driver gives me the most direct access: I write raw SQL (or the database's query shape), and I get back rows (or documents).
import pg from 'pg';
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
const { rows } = await client.query('SELECT id, name FROM users WHERE id = $1', [42]);
// rows: [{ id: 42, name: 'Ave' }]The $1 parameterized placeholder is the part that matters for security — it is how I avoid SQL injection, by letting the driver handle the escaping rather than concatenating user input into the query string. Native drivers give me full SQL power and the best performance, at the cost of writing every query by hand and mapping every row to a JavaScript object myself. For a small query surface or a performance-critical path, that is a fine place to live.
The middle rung: query builders
Knex.js is the canonical query builder — a JavaScript API that produces and runs SQL [5]. It does not model my domain; it just lets me write SQL-shaped code in JavaScript, with the SQL generation handled for me.
import knex from 'knex';
const db = knex({ client: 'pg', connection: process.env.DATABASE_URL });
const users = await db('users').select('id', 'name').where('id', 42);
// SELECT id, name FROM users WHERE id = 42The appeal is composability — I can build a query conditionally (add a where clause only if a filter is set) without string concatenation, and the builder handles the parameterization. Knex is the layer to reach for when I want SQL's full expressiveness but a more ergonomic, injection-safe authoring experience. It also ships a migration system, which is how schema changes are versioned and applied.
The top rung: ORMs
Object-Relational Mappers (ORMs) sit at the top of the ladder. An ORM maps database records to JavaScript/TypeScript objects (models), so I interact with the database through model methods rather than SQL. The ORM translates my model calls into queries, manages relationships (a user has many posts), runs migrations, and — in modern ORMs — gives me end-to-end type safety [6][7][8][9].
Prisma is the current standard for TypeScript-heavy projects. Its schema file declares the data model; a codegen step produces a fully-typed client; queries are validated at compile time [6].
// schema.prisma
model User {
id Int @id @default(autoincrement())
name String
posts Post[]
}const user = await prisma.user.findUnique({
where: { id: 42 },
include: { posts: true } // fetch the related posts in one query
});
// user is typed: { id: number, name: string, posts: Post[] }The include clause is the ORM's version of a join — related rows fetched in one typed call. Prisma's pitch is developer experience: the schema is the source of truth, the client is typed, migrations are generated from schema changes.
Drizzle is the newer, SQL-first ORM — it exposes a query-builder-style API that mirrors SQL closely, while still giving full type safety [7]. Where Prisma abstracts SQL away, Drizzle embraces it; developers who want to "see" the SQL they are generating tend to prefer Drizzle.
TypeORM and Sequelize are the older, Active-Record-style ORMs (where a model is both a row of data and the code that saves it), heavily influenced by their counterparts in other languages [8][9]. They support decorators (@Entity, @Column), model classes, and rich relationship helpers. They are mature and widely used in existing projects, though newer projects often lean toward Prisma or Drizzle for the type-safety story.
The NoSQL branch: Mongoose
For MongoDB specifically, Mongoose is the dominant ODM (Object Document Mapper) [10]. It brings a schema layer to MongoDB's otherwise schemaless documents — defining models, adding validation, and supporting middleware hooks (pre-save, post-find).
import mongoose from 'mongoose';
const User = mongoose.model('User', new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, unique: true }
}));
const user = await User.create({ name: 'Ave', email: 'ave@example.com' });The reason Mongoose exists even though MongoDB is schemaless is that real applications want validation and structure — a document with the wrong shape causes bugs downstream. Mongoose brings that structure at the application layer, while leaving MongoDB's flexibility for fields the schema does not cover.
Choosing a rung
The ladder is not a quality gradient; each rung is a trade-off:
- Native drivers — full SQL power, best performance, most manual work (queries, row mapping, migrations by hand). Right for small query surfaces or performance-critical paths.
- Query builders (Knex) — SQL expressiveness with composable, injection-safe authoring and a migration system. Right when SQL is the way you want to think but string concatenation is not.
- ORMs (Prisma, Drizzle, TypeORM, Sequelize) — models, relationships, migrations, type safety. Right when the domain has rich relationships and I want the tool to generate most of the data-access code.
- ODMs (Mongoose) — the NoSQL equivalent, bringing schema and validation to document stores.
The choice is how much SQL I want to write versus have generated, balanced against the type safety and ergonomics I need.
How I use this
The model I keep is the ladder, and the rung I pick depends on the project. For a TypeScript project with a relational database and a rich domain, I default to Prisma — the schema-as-source-of-truth and the typed client are decisive for developer velocity, and the generated migrations keep schema changes safe. For a small query surface or a script, a native driver is lighter weight and lets me write exactly the SQL I need. For MongoDB, Mongoose is the default unless I have a specific reason to use the bare driver. And I never concatenate user input into a query — every rung of the ladder offers parameterization, and using it is the entire defense against SQL injection. The ladder framing is what keeps the choice deliberate rather than reflexive.
References
[1] "What is Database?," Wikipedia. [Online]. Available: https://en.wikipedia.org/wiki/Database
[2] Amazon Web Services, "What is a database?," AWS. [Online]. Available: https://aws.amazon.com/what-is/database/
[3] "MongoDB Drivers," mongodb.com. [Online]. Available: https://www.mongodb.com/docs/drivers/
[4] DigitalOcean, "How To Create an HTTP Client with Core HTTP in Node.js." [Online]. Available: https://www.digitalocean.com/community/tutorials/how-to-create-an-http-client-with-core-http-in-node-js
[5] "Knex.js," knexjs.org. [Online]. Available: https://knexjs.org
[6] "Prisma Documentation," prisma.io. [Online]. Available: https://www.prisma.io/docs/
[7] "Drizzle Documentation," orm.drizzle.team. [Online]. Available: https://orm.drizzle.team/docs/overview
[8] "TypeORM," typeorm.io. [Online]. Available: https://typeorm.io
[9] "Sequelize," sequelize.org. [Online]. Available: https://sequelize.org/
[10] MongoDB, "Getting Started with MongoDB and Mongoose." [Online]. Available: https://www.mongodb.com/developer/languages/javascript/getting-started-with-mongodb-and-mongoose/
Knowledge check · Question 1 of 5
What does a native database driver (pg, mysql2) give you that an ORM does not?
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!