Database Integration (SQL and NoSQL)
Modern Node.js applications rarely exist in isolation. Whether you are building a REST API, a real-time chat application, or a data processing pipeline, database integration is the backbone that makes your data persist and stay queryable. This tutorial covers how to connect Node.js to both SQL databases (using PostgreSQL) and NoSQL databases (using MongoDB), with practical examples you can run in your own projects. If you are new to backend Node.js, start with the Node.js Express basics tutorial first.
What you will learn
By the end of this tutorial, you will know how to install database drivers, set up connection pools, define schemas, execute parameterized queries, and handle cleanup gracefully. The examples use pg for PostgreSQL and mongoose for MongoDB — two of the most widely used drivers in the Node.js ecosystem. Each code snippet is self-contained so you can copy it into a project and run it with only an environment variable set.
Understanding database options in Node.js
Node.js has access to an extensive ecosystem of database drivers and ORMs. The choice between SQL and NoSQL depends on your data structure requirements, query complexity, and scalability needs.
SQL databases like PostgreSQL and MySQL excel when your data has well-defined relationships and you need ACID compliance. They use structured schemas with tables, rows, and columns, and queries are written in SQL.
NoSQL databases like MongoDB and Redis offer flexible schemas and horizontal scaling. Data is stored as documents (similar to JSON), making them ideal for rapidly evolving data models or hierarchical structures.
Both approaches have merit, and many applications use both types for different purposes.
Connecting to PostgreSQL with the pg driver
The pg library is a popular pure JavaScript PostgreSQL client for Node.js. It provides a straightforward API for connecting to databases and executing queries.
Installing the driver
Start by adding the pg package to your project. This library communicates directly with PostgreSQL using the native protocol, so there is no extra middleware to configure:
npm install pg
Once installed, the Pool class gives you a managed set of connections. A pool reuses connections instead of opening a new one for every query, which keeps your app responsive under load. The connectionString typically comes from an environment variable so credentials stay out of source control:
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
async function query(sql, params) {
const client = await pool.connect();
try {
const result = await client.query(sql, params);
return result;
} finally {
client.release();
}
}
const result = await query("SELECT $1 AS number", ["1"]);
console.log(result.rows[0]);
The example above creates a pool and runs a quick parameterized query to verify the connection works. In production, you will want finer control over pool behaviour — maximum connections, idle timeouts, and connection timeouts prevent the driver from holding resources longer than necessary. Tuning these settings for your workload keeps database load predictable and avoids connection exhaustion under traffic spikes.
Using connection pooling
Configure pool limits to match your application’s expected concurrency. The max setting caps simultaneous connections; idleTimeoutMillis reclaims idle resources; and connectionTimeoutMillis prevents hangs when the database is slow to respond:
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
module.exports = pool;
The pool configuration above is a solid starting point for most web applications. If your app needs both SQL and document storage, PostgreSQL can serve as your relational workhorse while MongoDB handles flexible, schema-light data — the two databases often complement each other in the same stack.
Connecting to MongoDB with mongoose
Mongoose provides a schema-based solution for modeling MongoDB data. It enforces a shape on documents that MongoDB itself does not require, which helps catch data inconsistencies early instead of discovering them months later in production.
Installing Mongoose
npm install mongoose
After installing, the connection call opens a persistent socket to your MongoDB instance. The useNewUrlParser and useUnifiedTopology options align the driver with the current MongoDB server monitoring engine — without them, you may see deprecation warnings in your console at startup:
const mongoose = require("mongoose");
async function connect() {
await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
console.log("Connected to MongoDB");
}
connect();
A connection alone does not tell Mongoose what shape your documents should have. You define that with a schema, which describes field names, types, validation rules, and defaults. The schema is the contract between your application code and the documents MongoDB stores — once it is in place, Mongoose will reject writes that do not match:
const mongoose = require("mongoose");
const userSchema = new mongoose.Schema({
name: String,
email: { type: String, required: true, unique: true },
age: Number,
createdAt: { type: Date, default: Date.now },
});
const User = mongoose.model("User", userSchema);
With both PostgreSQL and MongoDB connections in place, the next priority is keeping those connections secure and stable. The four practices below apply regardless of which database you choose — they prevent the most common causes of outages and security incidents in production Node.js services.
Best Practices
1. Use environment variables
Never hardcode credentials. Storing connection strings in environment variables keeps secrets out of your version history and makes it straightforward to change database targets between development, staging, and production without touching code:
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
Environment variables protect you from the most common security mistake in database code: shipping credentials to a repository where they can be scraped by automated tools. They also let your hosting platform rotate secrets without a code deploy.
2. Handle errors properly
Always wrap database operations in try-catch blocks. A query can fail for many reasons: the connection may drop mid-request, the database may reject a malformed statement, or a timeout may fire. Catching those errors at the call site gives you a chance to log the useful parts and decide whether to retry or surface a message to the caller:
async function getUser(id) {
try {
const result = await pool.query("SELECT * FROM users WHERE id = $1", [id]);
return result.rows[0];
} catch (error) {
console.error("Database error:", error);
throw error;
}
}
The example re-throws the error after logging it. That preserves the stack for the caller while still giving you a server-side record. In a REST API, the top-level error handler can then translate a database error into an appropriate HTTP status code instead of leaking internal details to the client.
3. Use parameterized queries
Prevent SQL injection by using parameterized queries. When you pass values separately from the query text, the driver sends them as typed parameters — the database engine treats them as data, never as executable SQL. This is the single most effective defense against injection attacks:
// Good
await pool.query("SELECT * FROM users WHERE id = $1", [userId]);
// Bad - do not do this!
await pool.query("SELECT * FROM users WHERE id = " + userId);
The bad example concatenates user input directly into the SQL string. An attacker who passes 1; DROP TABLE users; as a userId would execute an extra statement. Parameterized queries make that class of attack impossible because the value is never parsed as SQL.
4. Close connections gracefully
When your process receives a termination signal, releasing database connections prevents orphaned sockets and lets in-flight queries complete. Register a shutdown handler that closes both the PostgreSQL pool and the Mongoose connection in the right order:
async function shutdown() {
await pool.end();
await mongoose.connection.close();
console.log("Connections closed");
}
process.on("SIGINT", shutdown);
Designing for real applications
Database code becomes easier to trust when it treats connection management, error handling, and query shape as first-class parts of the design. A pool is not just a performance trick. It is also a way to keep your app from opening more connections than the database can comfortably handle. Once you think about queries as shared resources instead of throwaway calls, the rest of the code starts to look different. You begin to ask where the pool lives, how it is closed, and what happens if a query times out or fails halfway through.
That same mindset applies to the schema. A table or collection should reflect how the application really reads and writes data. If the app fetches users by email all day, make that lookup easy. If a relationship matters, model it in a way that keeps joins or references understandable. Good database code does not just store information. It makes the common paths predictable, which helps both performance and maintainability.
Query safety and shape
Parameterized queries are one of the most important habits in any Node.js database app. They keep user input out of SQL syntax and make the intent of the query clear. Once you get used to passing values separately, it becomes natural to think about the query itself and the data that fills it. That separation also makes tests simpler because the structure of the query can be checked without building a string by hand.
It is also smart to keep result shape narrow. Fetch only the columns you need, and map the result into a shape that matches the rest of the app. That reduces data transfer and keeps the service layer from depending on database-specific details everywhere. If one function returns raw rows and another returns a normalized object, the rest of the code has to remember two formats. A consistent boundary makes the rest of the application much easier to read.
Operational habits
Database work does not end when the query succeeds. Long-running services need timeouts, shutdown logic, and a plan for connection failures. If the process is terminating, close the pool cleanly so work can finish in order. If a query fails, log the useful part of the error and decide whether the caller should retry or show a message. These small decisions matter because production issues usually happen at the edges, not during the happy path demo.
The other habit worth keeping is to test against realistic data shapes. A query that looks fine with a tiny fixture may behave differently with empty strings, long text, missing fields, or duplicate values. When you keep the model close to how the app is used, the code tends to stay honest. That saves time later because you are less likely to discover a mismatch only after the feature is already in front of users.
Both PostgreSQL and MongoDB are excellent choices for Node.js applications. PostgreSQL offers strong ACID compliance and complex query capabilities, while MongoDB provides flexibility and scalability for evolving data models. Use the right tool for your specific use case, and always follow security best practices like using environment variables and parameterized queries.
When to use which
Use PostgreSQL when:
- Your data has complex relationships that require joins
- You need ACID transactions for data integrity
- You have structured, predictable data schemas
- You need complex queries and aggregations
Use MongoDB when:
- Your data structure is flexible or evolving
- You are building a prototype or MVP quickly
- You need horizontal scalability
- Your data is document-oriented and naturally hierarchical
Many production applications use both - PostgreSQL for transactional data and MongoDB for flexible document storage.
Final database note
It is often useful to think of the database layer as a contract between your app and its data. The contract should be clear about what gets written, what gets read, and what happens when the data is not there. When that contract stays simple, the rest of the application can move faster because the data shape is easier to trust and easier to test.
Keeping the contract narrow also makes migrations easier later. If the app depends on a few predictable query paths, changing the storage engine or schema does not force a rewrite everywhere else.
Where to go next
Now that you have database connections working, the next step is building the API layer that sits on top of them. The express basics tutorial below walks through routing, middleware, and structuring a Node.js web server. For file uploads and static asset handling, the file system tutorial covers streams, buffers, and directory operations.