Lesson 15 of 20

Working with SQL Databases

Connecting to SQL Databases

A SQL database stores rows in tables with a fixed set of typed columns, and that structure is enforced by the database itself rather than by your application. It is the opposite trade from the previous lesson: less freedom while you are still deciding what a record should contain, and a firm guarantee afterwards that every row has the columns you expect, holding the types you expect.

Node talks to these databases through driver packages — mysql2 for MySQL and MariaDB, pg for PostgreSQL. Both offer callback and promise interfaces, and you want the promise one. Importing mysql2/promise rather than mysql2 is what lets the whole file be async/await like the rest of your code.

The object that matters is not a connection but a pool. Opening a database connection is expensive — a network handshake plus an authentication round trip — so opening one per request would spend most of your response time on setup. A pool opens a small number in advance and lends them out: a query borrows one, uses it, and hands it back. Create exactly one pool when the process starts and export it, which module caching from lesson 3 makes effortless.

That pool has a size, and the size is a real ceiling. When every connection is busy, further queries wait; wait long enough and requests start timing out. This is where the most damaging mistake in the lesson lives — borrowing a connection explicitly and forgetting to give it back. Each leaked connection permanently shrinks the pool, so the application runs perfectly for an hour and then stops serving anybody at all, which looks like a mysterious hang rather than a bug in a query.

Credentials go in environment variables without exception. A password written into source code is a password in your git history permanently, and it will be found. Read them from process.env, keep the real values in .env, and commit a .env.example so the next person knows which variables they need to set.

Example
// --- MySQL ---
// npm install mysql2

const mysql = require('mysql2/promise');

// Create a connection pool
// ONE pool for the whole application, created once when this module loads
const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,   // never a literal in source code
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10
});

module.exports = pool;   // module caching means every file shares this one pool

// --- PostgreSQL ---
// npm install pg

const { Pool } = require('pg');

const pgPool = new Pool({
  connectionString: process.env.DATABASE_URL,   // postgres://user:pass@host/dbname
  max: 10
});

// Test the connection
async function testConnection() {
  try {
    const [rows] = await pool.query('SELECT 1 + 1 AS result');
    console.log('MySQL connected:', rows[0].result); // 2
  } catch (err) {
    console.error('Connection failed:', err.message);
  }
}

testConnection();
  • mysql2/promise — the MySQL and MariaDB driver with promises, so await works
  • pg — the PostgreSQL driver; new Pool(...) is the equivalent
  • A pool lends out connections; opening one per request wastes most of your response time
  • connectionLimit / max — a real ceiling; queries queue once it is reached
  • Create one pool at module load and export it, never one per request
  • Credentials come from process.env — never from source code
Notes
  • The failure mode to watch for is a leaked connection. If you only ever call pool.query(...), the pool borrows and returns the connection for you and nothing can leak. The risk appears the moment you call pool.getConnection() yourself — usually for a transaction — because returning it is then your responsibility, and a query that throws in between will skip the release unless it lives in a finally.

Parameterised Queries and CRUD

SQL injection is the oldest serious web vulnerability and it is still near the top of every list of them, because the mistake that causes it feels so natural: building a query by joining strings together.

Make it concrete. "SELECT * FROM users WHERE email = '" + email + "'" with an email of ' OR '1'='1 becomes a query whose condition is true for every row, and the attacker logs in as whoever comes first. The same trick with a semicolon can run a second statement entirely — reading a table you never meant to expose, changing a price, or deleting everything. Nothing about your code looked dangerous.

A parameterised query fixes this properly rather than mitigating it. The SQL and the values travel to the database separately: the database parses the statement first, with placeholders marking where values will go, then fills those slots in as data. There is no moment at which a value could be read as SQL. This is why it is a fix and not an improvement — it is not escaping, and escaping is a guess about quoting that removes only the cases you thought of.

There is one real limit. You can parameterise values; you cannot parameterise identifiers — a table name, a column name, or the direction of an ORDER BY. So a sort option arriving from the query string cannot be a placeholder, and concatenating it is exactly how injection creeps back in through the sorting feature. Use an allow-list: map the strings you accept onto the column names you permit, and reject everything else.

Two smaller habits worth forming now. Do not write SELECT * in application code — name your columns, so that adding a password_hash column next month does not silently start returning it to clients. And notice what the driver hands back: mysql2 returns [rows, fields], so the destructuring in const [rows] = await pool.query(...) is load-bearing, and forgetting it leaves you with an array containing an array.

Example
const mysql = require('mysql2/promise');

// Assume pool is already created

// CREATE TABLE
async function createTable(pool) {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS users (
      id INT AUTO_INCREMENT PRIMARY KEY,
      name VARCHAR(100) NOT NULL,
      email VARCHAR(255) UNIQUE NOT NULL,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
  `);
}

// THE VULNERABILITY, written out so you recognise it on sight:
//   const sql = "SELECT * FROM users WHERE email = '" + email + "'";
//   email = "' OR '1'='1"              ->  the condition is true for every row
//   email = "'; DROP TABLE users; --"  ->  a second statement runs as well

// INSERT — Use ? placeholders (NEVER string concatenation)
async function createUser(pool, name, email) {
  const [result] = await pool.query(
    'INSERT INTO users (name, email) VALUES (?, ?)',
    [name, email]
  );
  return { id: result.insertId, name, email };
}

// SELECT
async function getUsers(pool) {
  const [rows] = await pool.query('SELECT * FROM users ORDER BY created_at DESC');
  return rows;
}

// SELECT by ID
async function getUserById(pool, id) {
  const [rows] = await pool.query('SELECT * FROM users WHERE id = ?', [id]);
  return rows[0] || null;
}

// UPDATE
async function updateUser(pool, id, name, email) {
  const [result] = await pool.query(
    'UPDATE users SET name = ?, email = ? WHERE id = ?',
    [name, email, id]
  );
  return result.affectedRows > 0;
}

// DELETE
async function deleteUser(pool, id) {
  const [result] = await pool.query('DELETE FROM users WHERE id = ?', [id]);
  return result.affectedRows > 0;
}

// Column names and sort direction CANNOT be placeholders.
// Use an allow-list rather than concatenating whatever the client sent.
const SORTABLE = { name: 'name', newest: 'created_at' };

async function listUsers(pool, sortKey, dir) {
  const column = SORTABLE[sortKey] || 'created_at';   // never the raw input
  const order = dir === 'asc' ? 'ASC' : 'DESC';
  const [rows] = await pool.query(
    `SELECT id, name, email, created_at FROM users ORDER BY ${column} ${order} LIMIT ?`,
    [50]
  );
  return rows;
}
  • ? in MySQL, $1 in PostgreSQL — the placeholder is the fix, not escaping
  • SQL and values travel separately, so a value can never be parsed as SQL
  • Identifiers cannot be placeholders — allow-list column names and sort direction
  • Never SELECT * in application code; name the columns you want
  • mysql2 returns [rows, fields] — that destructuring is load-bearing
  • An ORM parameterises for you, but raw SQL built by concatenation inside it does not
Notes
  • Be wary of the escaping helper some drivers provide. It looks like a solution and it quietly puts the responsibility back on you to remember it in every place, forever, including the query somebody adds in a hurry next month. Placeholders remove the decision entirely. The correct number of hand-escaped queries in a codebase is zero.

Designing the Tables

In SQL you declare the shape before storing anything, and that declaration is a genuine contract. A column marked NOT NULL cannot hold nothing; a column typed INT cannot hold the word "soon". This is the main practical difference from the previous lesson — the guarantee lives in the database, so no amount of careless application code can put a broken row in.

Every table gets a primary key, normally an auto-incrementing integer id. UNIQUE stops duplicates at the database level, which is stronger than a check in your code because two simultaneous requests cannot both slip past it. When a unique constraint is violated the driver throws a specific error — catch that error and answer 409, rather than letting it become an unexplained 500.

A foreign key says this column must refer to a real row in another table, and one line of it prevents the classic orphan problem: books belonging to a user who no longer exists. ON DELETE CASCADE removes the children along with the parent; ON DELETE RESTRICT refuses to delete a parent that still has children. Choose deliberately, because whichever you get by default is rarely the one you meant.

Indexes work as they did in the previous lesson. An index on the columns you filter and join by is the difference between an application that is fast with a hundred rows and one that is still fast with a hundred thousand. Foreign key columns and anything appearing in a WHERE clause are the obvious candidates. Do not index everything, though — each index has to be maintained on every write.

Finally, do not change a live schema by typing ALTER statements into a console. Keep each change as a numbered SQL file in your repository, applied in order, so the schema is versioned alongside the code that depends on it. Migration tools automate this; even a folder of numbered .sql files plus a record of which have been applied is enormously better than relying on memory.

Example
-- schema/001_init.sql — versioned, committed, applied in order
CREATE TABLE users (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  name       VARCHAR(80)  NOT NULL,
  email      VARCHAR(255) NOT NULL UNIQUE,   -- enforced by the database itself
  password   VARCHAR(255) NOT NULL,          -- a bcrypt hash, never a password
  role       ENUM('user','admin') NOT NULL DEFAULT 'user',
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE books (
  id        INT AUTO_INCREMENT PRIMARY KEY,
  title     VARCHAR(200) NOT NULL,
  author    VARCHAR(120) NOT NULL,
  owner_id  INT NOT NULL,
  CONSTRAINT fk_books_owner
    FOREIGN KEY (owner_id) REFERENCES users(id)
    ON DELETE CASCADE                        -- delete a user, delete their books
);

CREATE INDEX idx_books_owner ON books(owner_id);   -- you will filter by this constantly


// In Node: a duplicate-key violation is a 409, not a 500
try {
  await createUser(pool, name, email);
} catch (err) {
  if (err.code === 'ER_DUP_ENTRY' || err.code === '23505') {   // MySQL / PostgreSQL
    return res.status(409).json({ error: 'That email is already registered' });
  }
  throw err;
}
  • NOT NULL, typed columns and DEFAULT — the guarantee lives in the database
  • A primary key on every table; UNIQUE wherever duplicates must be impossible
  • FOREIGN KEY prevents orphan rows; choose CASCADE or RESTRICT deliberately
  • Index foreign keys and anything you filter by; do not index everything
  • Catch the duplicate-key error and answer 409 — ER_DUP_ENTRY or 23505
  • Keep schema changes as numbered files in the repository, never typed into a console
Notes
  • A database-level UNIQUE constraint is stronger than a check in your code, and the reason is timing. Two registration requests arriving in the same instant can both run "is this email taken?", both be told no, and both insert. The constraint is the only thing capable of rejecting the second one — which is why the check in your code is a convenience and the constraint is the actual rule.

Transactions, and Returning the Connection

Some operations are only correct if they happen completely or not at all. Transferring money is the textbook example; a more everyday one is creating an order and reducing a stock count, where finishing the first half and failing the second leaves you selling something you no longer have.

A transaction wraps several statements so the database applies all of them or none of them. Begin the transaction, run the statements, then commit if everything worked or roll back if anything threw. Until the commit, no other connection sees your half-finished changes, which is what makes the guarantee useful rather than merely tidy.

There is a mechanical detail that has to be right. A transaction lives on one connection. Three calls to pool.query may run on three different connections, and your transaction would then have nothing to do with the statements that followed it. So you take a connection out of the pool with getConnection(), run everything on that object, and commit or roll back on it too.

Which brings back the mistake from the first section. Once you have borrowed a connection you must return it, and the only place guaranteed to run is a finally block. Release inside try and a thrown query skips it; release only after the commit and every rollback path leaks. Each leaked connection permanently shrinks the pool, so after enough failures the application stops responding entirely — with no error at all, because everything is simply waiting.

Keep transactions short. Everything inside one holds locks that other requests may be queuing behind, so a slow HTTP call to a payment gateway in the middle of a transaction can stall the whole application. Do the outside work first, then open the transaction, write, and commit immediately.

Example
// A transaction must run on ONE connection, not on the pool
async function placeOrder(pool, userId, bookId) {
  const conn = await pool.getConnection();   // borrowed — you MUST give it back
  try {
    await conn.beginTransaction();

    const [rows] = await conn.query(
      'SELECT stock FROM books WHERE id = ? FOR UPDATE', [bookId]
    );
    if (!rows.length || rows[0].stock < 1) {
      throw new Error('OUT_OF_STOCK');
    }

    await conn.query('UPDATE books SET stock = stock - 1 WHERE id = ?', [bookId]);
    await conn.query(
      'INSERT INTO orders (user_id, book_id) VALUES (?, ?)', [userId, bookId]
    );

    await conn.commit();      // both changes, or neither of them
  } catch (err) {
    await conn.rollback();
    throw err;
  } finally {
    conn.release();           // the ONLY place this is safe to put
  }
}

// If you never call getConnection(), nothing can leak:
const [rows] = await pool.query(
  'SELECT id, title FROM books WHERE owner_id = ?', [userId]
);
  • A transaction is all-or-nothing: begin, then either commit or roll back
  • It must run on one connection — pool.getConnection(), not repeated pool.query
  • release() belongs in finally, and nowhere else
  • A leaked connection shrinks the pool permanently; enough of them and the app hangs
  • Keep transactions short — they hold locks that other requests are waiting on
  • Plain pool.query() borrows and returns for you, so it cannot leak
Notes
  • The symptom of a pool leak is worth memorising because it looks nothing like an ordinary bug: the application works perfectly, then gets slower, then stops answering entirely, with no errors in the log and the processor almost idle. Everything is waiting for a connection that will never come back. If that describes your outage, go and find the getConnection that has no finally.

SQL or MongoDB, and Where an ORM Fits

You have now seen both, so here is the honest comparison. A relational database is the right default when your data has relationships you care about, when the same shape repeats, and when correctness matters more than flexibility — orders, payments, attendance, anything involving money or rules somebody will argue about. Documents suit data whose shape genuinely varies, or that is always read as a single lump.

The strongest argument for SQL is that the database enforces the rules. Foreign keys, unique constraints, NOT NULL and transactions are guarantees that no amount of careless application code can break, and they keep holding when a second developer joins who has not read your validation layer. The strongest argument for MongoDB is that changing the shape costs nothing, which matters a great deal when you do not yet know what the shape is.

So the honest answer to "which is better" is a question back: what does the data look like, and what has to be guaranteed? Name a specific guarantee you would want and say why, and you will sound considerably better than somebody reciting that one is fast and the other scales, which is true of neither in the way the phrase suggests.

ORMs and query builders — Prisma, Sequelize, TypeORM, Knex — sit between you and the SQL. They remove repetitive statements, give you models and migrations, and parameterise by default, which is a real safety benefit. The cost is that you now debug two things instead of one, and it becomes easy to write something innocuous-looking that produces a wildly inefficient query. Every one of them has an escape hatch to raw SQL, and raw SQL inside an ORM is exactly as injectable as anywhere else if you build it by concatenation.

Learn plain SQL regardless of what you end up using. It is the layer everything else compiles down to, it is asked about in almost every backend interview, and being able to read the query your ORM actually produced is what turns a slow endpoint from a mystery into an index you forgot to create.

Example
// The same read, three ways

// 1. Raw SQL — you can see exactly what will run
const [rows] = await pool.query(
  'SELECT id, title FROM books WHERE owner_id = ? ORDER BY created_at DESC LIMIT 20',
  [userId]
);

// 2. A query builder — composable, and still obviously SQL
// const rows = await knex('books')
//   .select('id', 'title')
//   .where({ owner_id: userId })
//   .orderBy('created_at', 'desc')
//   .limit(20);

// 3. An ORM — convenient, and one more step away from the query that runs
// const rows = await prisma.book.findMany({
//   where: { ownerId: userId },
//   select: { id: true, title: true },
//   orderBy: { createdAt: 'desc' },
//   take: 20
// });

// Still injectable, in every ORM ever written, if you do this:
// await prisma.$queryRawUnsafe(`SELECT * FROM books WHERE title = '${input}'`);
  • SQL when relationships, fixed shapes and guarantees matter — orders, payments, records
  • Documents when the shape genuinely varies, or is always read as one piece
  • Constraints and transactions are guarantees your application code cannot break
  • ORMs parameterise by default and hide the query that actually runs
  • Raw SQL inside an ORM is just as injectable when built by concatenation
  • Learn plain SQL anyway — it is what every other layer turns into
Notes
  • "Which database is better" is a trap question and the answer that lands is a question back: what does the data look like, and what must be guaranteed? Then name something specific — a foreign key, a unique constraint, a transaction — and explain why you would or would not need it here. That is the difference between having used a database and having read about one.
Ask AI