Database Integration
Node.js Intermediate
Chapter 5 of 8 · Database Integration
Database Integration
Most real Node applications need a database. This chapter covers mysql2 — the de facto MySQL/MariaDB driver for Node — focusing on its promise API, prepared statements, and connection pooling. These three things together give you safe queries, good performance, and clean async code. The same patterns apply to any SQL driver, so the habits here transfer directly to PostgreSQL (pg), SQLite (better-sqlite3), and others.
Why mysql2, Not mysql
Promise support built in
Import from mysql2/promise to get an API that returns Promises natively — no need to promisify anything or use callbacks.
Prepared statements
execute() sends the SQL and the data separately, so the database parses the query once and the data can never be interpreted as SQL — the correct way to prevent SQL injection.
Better performance
Faster packet parsing than the original mysql package; the prepared-statement cache means repeated queries skip the parse step entirely.
Active maintenance
The original mysql package is largely unmaintained. mysql2 is its spiritual successor and is what the ecosystem now reaches for.
Connecting — Single Connection
A single connection is fine for scripts, CLIs, and learning. For web servers handling concurrent requests, use a pool (covered below).
🔴 Never Hardcode Credentials
Database passwords belong in environment variables (process.env.DB_PASSWORD), loaded from a .env file via dotenv in development and from your hosting platform's secrets manager in production. A password committed to source control is effectively public — rotate it immediately if it happens.
query() vs execute() — Know the Difference
Both methods run SQL, but they work differently under the hood. Use execute() for almost everything.
| Method | How it works | Use when |
|---|---|---|
query(sql, values) |
Interpolates values client-side using escaping, sends one complete SQL string to the server. | Dynamic identifiers (table/column names) that can't be parameterised, or one-off admin queries. |
execute(sql, values) |
Sends the SQL template and the values in separate packets. Server parses the template once and caches it. Data is never interpreted as SQL. | Every query that accepts user-supplied data. Also faster for repeated queries. |
Reading Query Results
Both query() and execute() resolve to a two-element array: [rows, fields]. Destructuring the first element is the idiomatic pattern.
Placeholders — ? and Named
mysql2 supports two placeholder styles. Positional ? is universal; named placeholders require enabling an option and use a slightly different syntax.
💡 You Can't Parameterise Identifiers
Placeholders only work for values — not table names, column names, or SQL keywords. If you need a dynamic identifier, validate it against an allowlist of known names and interpolate it yourself:
const ALLOWED = ['name', 'email', 'created_at'];
if (!ALLOWED.includes(col)) throw new Error('Invalid column');
conn.execute(`SELECT ${col} FROM users WHERE id = ?`, [id])
Connection Pooling
A web server handles many requests concurrently. A single connection processes one query at a time — all others queue behind it. A pool maintains several open connections, handing one to each incoming request and returning it when the query finishes. This is how databases are used in production.
💡 Create the Pool Once, Use It Everywhere
Instantiate the pool once when your app starts — typically in a db.js module that exports it — and import it wherever you need it. Creating a new pool per request defeats the purpose and exhausts the database's connection limit.
// db.js
const pool = mysql.createPool({ ... });
module.exports = pool;
// anywhere else
const pool = require('./db');
const [rows] = await pool.execute('SELECT ...');
Transactions
A transaction groups multiple queries so they either all succeed or all fail together. Essential for operations that must be atomic — transferring money, creating an order and decrementing stock, linking rows across tables.
Error Handling
Database errors are thrown as standard JavaScript errors with extra properties. The most useful ones:
Structuring Database Code — A db.js Module
Rather than scattering pool.execute() calls through your route handlers, collect SQL queries into a dedicated module. Each function wraps one logical operation and handles its own errors, keeping routes thin.
⚠️ Common Mistakes
Using query() with user input — always use execute() for any value that comes from outside your code. query()'s escaping is client-side and can be bypassed in edge cases; execute()'s prepared-statement approach is structural and cannot.
Forgetting conn.release() in a finally block — if an error is thrown before release(), the connection leaks back into the pool unusable, eventually starving the application of connections under load.
Creating a pool per request — a pool should be a module-level singleton. Creating one per HTTP request spins up new connections on every hit and exhausts the database server's connection limit quickly.
Coding Challenges
Challenge 1 — User Repository
Build a UserRepository class that wraps a mysql2 pool and exposes: findById(id), findByEmail(email), create({ name, email, passwordHash }), update(id, fields) (where fields is a partial object of columns to update), and delete(id). The update() method must build the SET clause dynamically from the provided fields while still using parameterised values — never string-interpolate user data. Return null from finders when no row is found.
Challenge 2 — Transactional Order Placement
Write a placeOrder(userId, items) function where items is an array of { productId, quantity }. The function must, within a single transaction: (1) verify each product exists and has sufficient stock, (2) create an order row, (3) insert one order_items row per item, (4) decrement the stock for each product. If any step fails — including insufficient stock — roll back the entire transaction and throw a descriptive error. Return the new order ID on success.
Challenge 3 — Paginated Search
Write a searchUsers(options) function that accepts { query, page, pageSize, orderBy, orderDir } and returns { data, total, page, pageSize, totalPages }. The text search should match against both name and email using LIKE. The orderBy column must be validated against an allowlist before being interpolated into the SQL. The function should run the data query and the count query in parallel using Promise.all to minimise round-trip time.