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.
| Concern | SQL (PostgreSQL, MySQL) | NoSQL (MongoDB, Redis) |
|---|---|---|
| Schema | Enforced, migration-driven | Flexible / schemaless |
| Relationships | Native joins, foreign keys | Embedded docs or app-side joins |
| Transactions | Strong ACID guarantees | Limited (improving in MongoDB) |
| Scaling | Vertical, read replicas | Horizontal sharding |
| Best for | Financial, relational data | Catalogs, 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.
| Approach | Examples | Pros | Cons |
|---|---|---|---|
| Raw driver | pg, mysql2, mongodb | Full control, no overhead | Manual SQL, more boilerplate |
| ORM / ODM | Prisma, Sequelize, Mongoose | Type safety, migrations, less code | Abstraction cost, learning curve |
| Query builder | Knex, Kysely | SQL-like, composable | Still 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/catchand forward errors withnext(err)on Express 4.x. - Release pooled clients in a
finallyblock so a thrown error can’t leak connections. - Tune
maxpool size to your database’s limit and the number of app instances. - Add a graceful shutdown hook that calls
pool.end()onSIGTERMto drain connections.