NodeJSMySQL with Node.js

MySQL with Node.js

MySQL (and its drop-in fork MariaDB) is the other dominant open-source relational database. The modern driver for Node is mysql2 — faster than the original mysql package and, crucially, promise-capable. Most concepts carry over directly from Postgres: a pool, parameterized queries, transactions. This page highlights what's the same and the MySQL-specific differences that trip people up.

Use `mysql2`, and its promise API

Bash
npm install mysql2

db.js

JS
// Import the PROMISE build so you can await queries:
import mysql from 'mysql2/promise'

export const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: process.env.DB_NAME,
  connectionLimit: 10,
  waitForConnections: true,
})
Import `mysql2/promise` — the default export uses callbacks
`mysql2`'s default entry point is callback-based; `await pool.query(...)` on it won't work. Import `mysql2/promise` to get the promise interface (or wrap with `.promise()`). Prefer `mysql2` over the older `mysql` package — it's faster, supports prepared statements, and is actively maintained. Create the **pool** once at startup, as with every database.
Parameterized queries — `?` placeholders

JS
// MySQL uses ? placeholders (Postgres uses $1, $2):
const [rows] = await pool.query(
  'SELECT id, name FROM users WHERE email = ? AND active = ?',
  [req.body.email, true],
)

// query() returns [rows, fields] — destructure the first element:
const user = rows[0]
MySQL placeholders are `?`, and `query()` returns `[rows, fields]`
Two differences from `pg`: placeholders are positional `?` (not `$1`), and a query resolves to a **tuple** `[rows, fields]` — so you destructure `const [rows] = await pool.query(...)`, not `const { rows }`. As always, parameterize — never interpolate user input into the SQL string, or you've opened a [SQL injection](/nodejs/databases-intro) hole. The same safety rule, different placeholder syntax.
`query` vs `execute` (prepared statements)

JS
// query()   — sends SQL each time; placeholders escaped client-side.
const [a] = await pool.query('SELECT * FROM users WHERE id = ?', [id])

// execute() — uses a PREPARED statement (parsed once, reused); slightly safer/faster
//             for repeated queries:
const [b] = await pool.execute('SELECT * FROM users WHERE id = ?', [id])
Prefer `execute()` for repeated parameterized queries
`execute()` creates a server-side prepared statement that MySQL parses once and reuses, which is marginally faster for hot, repeated queries and keeps data strictly separate from SQL. `query()` is fine for one-offs and supports a few things `execute` doesn't (like `?`-expansion of arrays for `IN`). When in doubt for parameterized reads/writes, `execute()` is a good default.
Writes — `insertId` and `affectedRows`

JS
const [result] = await pool.execute(
  'INSERT INTO users (name, email) VALUES (?, ?)',
  ['Ada', 'ada@x.com'],
)
result.insertId       // the new AUTO_INCREMENT id
result.affectedRows   // rows inserted/updated/deleted

const [upd] = await pool.execute('UPDATE users SET name = ? WHERE id = ?', ['Ada L.', 1])
if (upd.affectedRows === 0) { /* no such row → 404 */ }
MySQL has no `RETURNING` — use `insertId` then re-query if needed
Unlike [Postgres's `RETURNING`](/nodejs/postgresql), MySQL can't return the inserted row from the `INSERT` itself. You get `result.insertId` (the new auto-increment key) and `affectedRows`; if you need the full created row, run a follow-up `SELECT ... WHERE id = ?`. Check `affectedRows` to detect no-op updates/deletes and map them to [404](/nodejs/api-error-responses).
Transactions

JS
const conn = await pool.getConnection()
try {
  await conn.beginTransaction()
  await conn.execute('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1])
  await conn.execute('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2])
  await conn.commit()
} catch (err) {
  await conn.rollback()
  throw err
} finally {
  conn.release()              // return the connection to the pool
}
Get a dedicated connection and `release()` it in `finally`
As with Postgres, a transaction must run on a **single** checked-out connection (`pool.getConnection()`), and you must `release()` it in `finally` or leak it. `mysql2` exposes `beginTransaction()`/`commit()`/`rollback()` helpers instead of raw `BEGIN`/`COMMIT` strings — same semantics. See [transactions](/nodejs/transactions).
MySQL gotchas

Topic

Watch out for

Storage engine

Use InnoDB (default) — MyISAM has no transactions/FKs

Charset

Use utf8mb4, not utf8 (the latter can't store all emoji)

Booleans

BOOLEAN is an alias for TINYINT(1) — comes back as 0/1

Dates

timezone config affects DATETIME/TIMESTAMP handling

Decimals

Use DECIMAL for money — never FLOAT/DOUBLE

Use `utf8mb4` and `DECIMAL` for money — two classic MySQL traps
MySQL's confusingly-named `utf8` is a 3-byte subset that **can't store emoji or some characters** — always use `utf8mb4` for true UTF-8. And never store currency in `FLOAT`/`DOUBLE` (rounding errors); use `DECIMAL(10,2)` or integer cents. Also ensure tables use **InnoDB** so [transactions](/nodejs/transactions) and foreign keys actually work — older MyISAM tables silently ignore both.
Postgres or MySQL?
  • Both are mature, fast, free, and well-supported in Node — you can build anything on either.

  • Postgres leans richer: JSONB, arrays, stricter SQL compliance, advanced indexing, RETURNING.

  • MySQL/MariaDB is ubiquitous in shared hosting and the LAMP world; very fast for simple read-heavy loads.

  • Either pairs with the same ORMs (Prisma, Sequelize, TypeORM) — switching later is mostly config.

Next
Trade raw SQL for models with a mature ORM: [Sequelize ORM](/nodejs/sequelize).