Skip to content
Node.js nd database 4 min read

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.

TypeDatabaseBest forNode driver
RelationalPostgreSQLComplex queries, integrity, JSONpg
RelationalMySQL / MariaDBWeb apps, broad hosting supportmysql2
RelationalSQLiteEmbedded, local, single-filenode:sqlite, better-sqlite3
DocumentMongoDBFlexible schemas, fast iterationmongodb
Key-valueRedisCaching, sessions, queuesredis, ioredis

Node 22 ships an experimental built-in node:sqlite module, so you can use SQLite with zero dependencies. For production use today, better-sqlite3 remains 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.

ApproachProsCons
Native driverFast, transparent, full controlManual mapping, hand-written queries
Query builder (Knex, Kysely)Composable, typed, no magicStill write query logic by hand
ORM/ODM (Prisma, Drizzle, Mongoose, Sequelize)Productivity, migrations, type safetyAbstraction 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.
Last updated June 14, 2026
Was this helpful?