Database Connectivity in Node.js
Almost every non-trivial Node.js application needs to persist data, and the ecosystem offers a database for every shape of problem — from rigid relational schemas to flexible document stores and in-memory caches. Node connects to these systems through drivers (low-level clients that speak the database’s wire protocol) and, optionally, through ORMs or ODMs that layer a higher-level, object-oriented API on top. Choosing the right database and the right level of abstraction up front saves enormous effort later, so it pays to understand the landscape before writing your first query.
SQL vs NoSQL
The first decision is the data model. SQL (relational) databases store data in tables with fixed columns and enforce relationships, constraints, and transactions — ideal when data is structured and consistency matters. NoSQL databases trade some of that rigidity for flexibility and scale: document stores hold schema-light JSON-like records, while key-value stores excel at fast, simple lookups.
| Type | Database | Best for | Node driver |
|---|---|---|---|
| Relational | PostgreSQL | Complex queries, integrity, JSON | pg |
| Relational | MySQL / MariaDB | Web apps, broad hosting support | mysql2 |
| Relational | SQLite | Embedded, local, single-file | node:sqlite, better-sqlite3 |
| Document | MongoDB | Flexible schemas, fast iteration | mongodb |
| Key-value | Redis | Caching, sessions, queues | redis, ioredis |
Node 22 ships an experimental built-in
node:sqlitemodule, so you can use SQLite with zero dependencies. For production use today,better-sqlite3remains the most popular choice.
Native drivers vs ORMs and ODMs
A native driver gives you direct, minimal-overhead access: you send queries (SQL strings or document commands) and receive raw results. An ORM (Object-Relational Mapper, for SQL) or ODM (Object-Document Mapper, for document databases) sits on top of the driver and maps rows or documents to objects, generates queries for you, and adds features like migrations, validation, and relationship loading.
| Approach | Pros | Cons |
|---|---|---|
| Native driver | Fast, transparent, full control | Manual mapping, hand-written queries |
| Query builder (Knex, Kysely) | Composable, typed, no magic | Still write query logic by hand |
| ORM/ODM (Prisma, Drizzle, Mongoose, Sequelize) | Productivity, migrations, type safety | Abstraction overhead, learning curve |
A direct query with the native PostgreSQL driver looks like this:
import pg from 'pg';
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
const { rows } = await client.query(
'SELECT id, email FROM users WHERE active = $1',
[true],
);
console.log(`Found ${rows.length} active users`);
console.log(rows[0]);
await client.end();
Output:
Found 2 active users
{ id: 1, email: '[email protected]' }
The same idea expressed through an ORM (Prisma) reads as object access rather than SQL:
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const users = await prisma.user.findMany({
where: { active: true },
select: { id: true, email: true },
});
console.log(`Found ${users.length} active users`);
await prisma.$disconnect();
Always pass user-supplied values as parameters (
$1,?) rather than concatenating them into the query string. This is your primary defense against SQL injection, and ORMs apply it automatically.
Connection management
Opening a database connection is expensive — it involves a network handshake and authentication. Creating a fresh connection per request would cripple a busy server, so production apps use a connection pool: a fixed set of reusable connections that requests borrow and return. Most drivers provide a pool out of the box.
import pg from 'pg';
// A pool is created once and shared across the whole app.
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // maximum concurrent connections
idleTimeoutMillis: 30000,
});
// `pool.query` checks out a connection, runs the query, and returns it.
const { rows } = await pool.query('SELECT now() AS ts');
console.log(`Server time: ${rows[0].ts.toISOString()}`);
Output:
Server time: 2026-06-14T10:42:18.301Z
Document and key-value clients manage pooling internally — you create a single client and reuse it:
import { MongoClient } from 'mongodb';
const client = new MongoClient(process.env.MONGO_URL);
await client.connect();
const db = client.db('shop');
const count = await db.collection('orders').countDocuments({ status: 'paid' });
console.log(`Paid orders: ${count}`);
Output:
Paid orders: 1284
The cardinal rule is to create your pool or client once at startup and share it across the application — never per request. Store connection credentials in environment variables (loaded from a .env file with --env-file or a tool like dotenv), never in source control.
Best Practices
- Pick SQL for structured, relational data with strong consistency needs, and NoSQL for flexible schemas, caching, or massive scale.
- Create a single connection pool or client at startup and reuse it for the lifetime of the process — don’t reconnect per request.
- Always parameterize queries to prevent SQL injection; never interpolate user input into query strings.
- Keep credentials in environment variables (Node 20+ supports
--env-file), not hard-coded in your codebase. - Start with a native driver for simple needs; adopt an ORM/ODM when migrations, validation, and type safety justify the extra abstraction.
- Handle pool errors and gracefully close connections on shutdown (
pool.end(),client.close()) to avoid leaking sockets. - Pin to an active LTS release (Node 20 or 22) so driver compatibility and async APIs stay stable.