Skip to content
Express.js ex data 5 min read

Connecting Databases to Express

Express is unopinionated about persistence — it ships with no database layer, leaving you free to wire in whatever store fits your app. That freedom means a few up-front decisions: SQL or NoSQL, a raw driver or an ORM/ODM, and where the connection lives so it’s reused across requests rather than reopened on every call. This page surveys those choices and the connection patterns that keep an Express app fast and stable under load.

Choosing SQL vs NoSQL

The first decision is the shape of your data. SQL (relational) databases like PostgreSQL, MySQL, and SQLite store rows in typed, schema-enforced tables and excel at joins, transactions, and data integrity. NoSQL databases like MongoDB store flexible, document-shaped data and trade strict schemas for horizontal scalability and developer velocity.

ConcernSQL (PostgreSQL, MySQL)NoSQL (MongoDB, Redis)
SchemaEnforced, migration-drivenFlexible / schemaless
RelationshipsNative joins, foreign keysEmbedded docs or app-side joins
TransactionsStrong ACID guaranteesLimited (improving in MongoDB)
ScalingVertical, read replicasHorizontal sharding
Best forFinancial, relational dataCatalogs, events, caching

Don’t over-index on “scale” early. Most apps are well served by a single PostgreSQL instance for years. Reach for NoSQL when your access patterns — not your hype cycle — demand it.

Drivers vs ORMs and ODMs

Once you’ve picked a database, you choose how to talk to it. A driver is the low-level client that speaks the database’s wire protocol — pg for PostgreSQL, mysql2 for MySQL, the native mongodb driver for Mongo. You write queries by hand and get full control with minimal abstraction.

An ORM (Object-Relational Mapper, for SQL) or ODM (Object-Document Mapper, for NoSQL) sits on top of the driver and maps database records to JavaScript objects, generating queries for you. Sequelize and Prisma are popular ORMs; Mongoose is the dominant Mongo ODM.

ApproachExamplesProsCons
Raw driverpg, mysql2, mongodbFull control, no overheadManual SQL, more boilerplate
ORM / ODMPrisma, Sequelize, MongooseType safety, migrations, less codeAbstraction cost, learning curve
Query builderKnex, KyselySQL-like, composableStill write query logic

For a typed, modern experience Prisma is hard to beat; for raw speed and SQL fluency the pg driver is excellent.

Where to put connection setup

The cardinal rule: create the connection (or pool) once at startup and reuse it across requests. Opening a connection per request exhausts the database and adds latency. Put the setup in a dedicated module and import the shared instance wherever you need it.

// db.js — single shared pool for the whole app
import pg from "pg";

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  idleTimeoutMillis: 30000,
});

export default pool;

Route handlers import that pool and run async queries. With Express 4.x you must wrap async handlers so rejected promises reach the error middleware; Express 5.x forwards them automatically.

// routes/users.js
import { Router } from "express";
import pool from "../db.js";

const router = Router();

router.get("/:id", async (req, res, next) => {
  try {
    const { rows } = await pool.query(
      "SELECT id, name, email FROM users WHERE id = $1",
      [req.params.id]
    );
    if (rows.length === 0) return res.status(404).json({ error: "Not found" });
    res.json(rows[0]);
  } catch (err) {
    next(err); // hand off to error-handling middleware
  }
});

export default router;

Mount the router and add a centralized error handler in your app entry point.

// app.js
import express from "express";
import usersRouter from "./routes/users.js";

const app = express();
app.use(express.json());
app.use("/users", usersRouter);

// error-handling middleware (4 args)
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "Internal Server Error" });
});

app.listen(3000, () => console.log("Listening on :3000"));

A request to GET /users/42 returns:

Output:

{
  "id": 42,
  "name": "Ada Lovelace",
  "email": "[email protected]"
}

Connection pooling basics

A connection pool keeps a set of open connections ready to be borrowed, used, and returned — avoiding the expensive TCP and auth handshake on every query. Both the pg and mysql2 drivers expose pools, and ORMs manage one internally. Size the pool to your database’s connection limit divided across your app instances; a common starting point is max: 10 per process.

A pool that’s too large can overwhelm the database — Postgres defaults to ~100 total connections. If you run multiple app instances or use serverless functions, put a pooler like PgBouncer in front of the database.

For long transactions or multi-statement work, check out a dedicated client from the pool and release it in a finally block:

const client = await pool.connect();
try {
  await client.query("BEGIN");
  await client.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [100, 1]);
  await client.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [100, 2]);
  await client.query("COMMIT");
} catch (err) {
  await client.query("ROLLBACK");
  throw err;
} finally {
  client.release(); // always return the connection
}

Best Practices

  • Keep all secrets — connection strings, passwords — in environment variables, never in source.
  • Create one pool/connection per process at startup and share it; never connect per request.
  • Always use parameterized queries ($1, ?) to prevent SQL injection.
  • Wrap async handlers in try/catch and forward errors with next(err) on Express 4.x.
  • Release pooled clients in a finally block so a thrown error can’t leak connections.
  • Tune max pool size to your database’s limit and the number of app instances.
  • Add a graceful shutdown hook that calls pool.end() on SIGTERM to drain connections.
Last updated June 14, 2026
Was this helpful?