mysql2
mysql2 is a MySQL and MariaDB client for Node.js written entirely in JavaScript, with no native bindings to compile. It speaks the MySQL wire protocol directly, which is how it gets support for prepared statements, the binary log protocol, connection compression, TLS, and non-UTF8 character sets. The API was deliberately kept compatible with the older mysqljs/mysql package, so createConnection, createPool, query, and escape all behave the way tutorials from a decade ago say they do. The difference that matters day to day is the promise entry point: require('mysql2/promise') gives you the same objects with async methods, and every result comes back as a destructurable [rows, fields] tuple. It is a driver and nothing more. There is no schema, no migrations, no model layer, no query builder.
If your Node service talks to MySQL, mysql2 is the driver, and picking anything else needs a specific reason. Budget an hour early on for the execute() versus query() distinction and for the timezone, DECIMAL, and BIGINT conversion options, because every one of those produces a bug that looks like a data problem rather than a config problem.
Use it if
- You are talking to MySQL 5.7, MySQL 8.x, or MariaDB from Node and you want the query you wrote to be the query that runs, with no ORM translating it first
- You want real server-side prepared statements. connection.execute() uses the binary protocol and caches the statement handle per connection, which matters when the same query runs thousands of times per minute
- You need a driver with zero native build steps. It installs the same way on Alpine, on a Mac laptop, and inside a slim Docker image, so no node-gyp, no Python, no libmysqlclient
- You are already using Sequelize, Knex, Drizzle, or TypeORM against MySQL. All of them ask you to install mysql2 as the underlying driver, so you have it whether you call it directly or not
- You need protocol-level features most wrappers hide: LOAD DATA LOCAL INFILE, streaming result rows, compression, multi-statement queries, or reading the MySQL binary log
- You expect execute() and query() to be interchangeable. They are not. query() does client-side interpolation, so it expands an array into a comma list for IN (?) and lets you inject identifiers; execute() sends the parameters to the server separately, where an array is one value and an identifier placeholder is a syntax error. This trips up nearly everyone at least once
- You want typed rows. TypeScript support means casting to RowDataPacket[] or ResultSetHeader yourself; the driver has no idea what your columns are. If you want the compiler to know your schema, Drizzle or Kysely on top of mysql2 is the answer, not mysql2 alone
- You are running in a serverless function that scales to hundreds of concurrent invocations. TCP connection pooling does not survive that model well, and you will hit max_connections on the server. A pooler in front, or an HTTP-based driver for your provider, is the fix
- You need DECIMAL and BIGINT to come back as numbers by default. They come back as strings unless you set decimalNumbers and supportBigNumbers, and DATETIME columns come back as JS Date objects interpreted in the connection timezone, which produces off-by-hours bugs on servers that are not in UTC
- You want a project with deep maintainer bench strength. It is effectively one maintainer plus contributors, with 421 open issues (458 counting PRs) as of early August 2026. Releases are frequent, but a question in an issue can sit for a long time
- You want an ESM-native package. It ships CommonJS with an exports map for '.' and './promise' only, so bundlers and Node ESM get a default import rather than real named exports
Setup reality
npm install mysql2 and you are done: it is pure JavaScript, so there is no node-gyp step, no compiler, and no libmysqlclient to find. Two things about the install are worth knowing. First, it declares @types/node as a peer dependency at '>= 8', which npm 7 and later will install into your tree automatically whether or not you write TypeScript. Second, the package has eight runtime dependencies (iconv-lite, long, denque, lru.min, named-placeholders, generate-function, sql-escaper, aws-ssl-profiles), so it is not the two-file module people assume. The real setup friction is picking an entry point and sticking to it. require('mysql2') gives callbacks, require('mysql2/promise') gives promises, and objects from one do not work with the other; a callback pool has a .promise() method to convert, which is the escape hatch when a library hands you the wrong one. After that, three connection options usually need setting before anything behaves: timezone (or dateStrings: true) so DATETIME does not shift, decimalNumbers if you want DECIMAL as a number instead of a string, and ssl for managed MySQL. For RDS and Aurora, ssl: 'Amazon RDS' pulls the bundled CA chain from aws-ssl-profiles rather than making you download PEM files.
Patterns
Connect with the promise API and run a queryconnect-and-query
import mysql from 'mysql2/promise'
const conn = await mysql.createConnection({
host: '127.0.0.1',
user: 'app',
password: process.env.DB_PASSWORD,
database: 'shop',
})
const [rows, fields] = await conn.execute(
'SELECT id, email FROM users WHERE age > ?',
[21],
)
console.log(rows)
await conn.end()Every query resolves to a two element array, not to the rows. Forgetting to destructure gives you an array whose first element is the row set, which then quietly serializes wrong in an API response. Use conn.end() to flush and close; conn.destroy() drops the socket immediately and loses in-flight results.
Use a pool instead of a connection per requestconnection-pool
import mysql from 'mysql2/promise'
export const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: 'shop',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
enableKeepAlive: true,
keepAliveInitialDelay: 10000,
})
const [rows] = await pool.query('SELECT 1 AS ok')connectionLimit defaults to 10 per process, so four workers means forty connections before you have any traffic. queueLimit: 0 means an unbounded wait queue, which turns a database stall into unbounded memory growth; set a real number in production. enableKeepAlive matters behind load balancers that silently drop idle TCP.
Know when execute() and query() differprepared-statements
// execute(): server-side prepared statement, params sent separately
const [rows] = await pool.execute(
'SELECT * FROM users WHERE id = ?',
[userId],
)
// query(): client-side interpolation, expands arrays and identifiers
const ids = [1, 2, 3]
const [many] = await pool.query(
'SELECT * FROM users WHERE id IN (?)',
[ids],
)
// this THROWS on execute(): the array is one parameter, not a list
// await pool.execute('SELECT * FROM users WHERE id IN (?)', [ids])execute() caches one prepared statement handle per connection per SQL string, capped by maxPreparedStatements (16000 by default). Generating SQL strings dynamically with execute() fills that cache and then thrashes it, so build the SQL once and vary only the parameters.
Insert many rows in one round tripbulk-insert
const rows = [
['ada@example.com', 36],
['alan@example.com', 41],
]
const [result] = await pool.query(
'INSERT INTO users (email, age) VALUES ?',
[rows],
)
console.log(result.affectedRows, result.insertId)Nested array expansion only works with query(), never execute(), and the placeholder is a bare ? with no parentheses around it. insertId is the id of the FIRST row inserted in the batch, not the last, so derive the rest by adding an offset only if auto_increment_increment is 1.
Use :name placeholders instead of positional ?named-placeholders
const conn = await mysql.createConnection({
host: '127.0.0.1',
database: 'shop',
namedPlaceholders: true,
})
const [rows] = await conn.execute(
'SELECT * FROM users WHERE age > :minAge AND country = :country',
{ minAge: 21, country: 'IN' },
)This is off by default and can also be flipped per query by passing { sql, namedPlaceholders: true }. It works by rewriting the SQL into positional parameters before sending, so the prepared statement cache still keys on the rewritten string, and a missing key in the object throws rather than binding NULL.
Run a transaction on a single pooled connectiontransactions
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
await conn.execute('UPDATE accounts SET bal = bal - ? WHERE id = ?', [100, 1])
await conn.execute('UPDATE accounts SET bal = bal + ? WHERE id = ?', [100, 2])
await conn.commit()
} catch (error) {
await conn.rollback()
throw error
} finally {
conn.release()
}You must take a connection out of the pool. Calling pool.execute() three times can land on three different connections, so BEGIN and COMMIT end up on different sessions and nothing is transactional. release() in a finally block is not optional; a thrown error without it leaks the connection until the pool starves.
Stream rows instead of buffering the whole resultstream-large-results
import { pipeline } from 'node:stream/promises'
const conn = await pool.getConnection()
try {
const stream = conn.connection
.query('SELECT * FROM events WHERE created_at > ?', ['2026-01-01'])
.stream({ highWaterMark: 500 })
for await (const row of stream) {
await handle(row)
}
} finally {
conn.release()
}Streaming lives on the callback connection object, which is why the promise pool exposes it as conn.connection. The connection cannot serve another query until the stream is fully drained or destroyed, so an early break without destroying the stream wedges that pooled connection.
Type the result of a query in TypeScripttypescript-row-types
import mysql, { RowDataPacket, ResultSetHeader } from 'mysql2/promise'
interface User extends RowDataPacket {
id: number
email: string
}
const [users] = await pool.execute<User[]>(
'SELECT id, email FROM users WHERE id = ?',
[1],
)
const [result] = await pool.execute<ResultSetHeader>(
'DELETE FROM users WHERE id = ?',
[1],
)
console.log(result.affectedRows)The generic is an unchecked assertion, not validation. If the SELECT list and the interface drift apart, TypeScript still says the code is fine and you get undefined at runtime. Write queries must be typed as ResultSetHeader, because RowDataPacket[] on a DELETE gives you a type with no affectedRows.
Branch on MySQL error codeshandle-mysql-errors
try {
await pool.execute('INSERT INTO users (email) VALUES (?)', [email])
} catch (error) {
switch (error.code) {
case 'ER_DUP_ENTRY':
throw new Conflict('email already registered')
case 'ER_LOCK_DEADLOCK':
return retryLater()
case 'PROTOCOL_CONNECTION_LOST':
case 'ECONNRESET':
return retryLater()
default:
throw error
}
}Errors carry code (a string like ER_DUP_ENTRY), errno, sqlState, and sqlMessage. Match on code, never on the message text, which changes between MySQL and MariaDB and between server versions. Deadlocks are normal under concurrency and should be retried, not logged as bugs.
Stop DATETIME, DECIMAL, and BIGINT surprisesdate-and-number-types
const conn = await mysql.createConnection({
host: '127.0.0.1',
database: 'shop',
timezone: 'Z', // interpret DATETIME as UTC
dateStrings: ['DATE'], // keep DATE as 'YYYY-MM-DD'
decimalNumbers: true, // DECIMAL as Number instead of string
supportBigNumbers: true,
bigNumberStrings: true, // BIGINT as string, no silent precision loss
})All of these default to off, which is why a price column arrives as '19.99' and a bigint id arrives rounded. decimalNumbers: true trades string exactness for float rounding, so for money keep the string and parse it with a decimal library instead.
Connect over TLS to a managed MySQLtls-managed-mysql
// RDS and Aurora: bundled CA chain, no PEM download
const rds = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: 'shop',
ssl: 'Amazon RDS',
})
// any other provider: pass tls.connect options
import { readFileSync } from 'node:fs'
const other = await mysql.createConnection({
host: process.env.DB_HOST,
ssl: { ca: readFileSync('./ca.pem'), rejectUnauthorized: true },
})The 'Amazon RDS' string is resolved by the bundled aws-ssl-profiles dependency. Setting ssl: { rejectUnauthorized: false } makes the connection encrypted but unauthenticated, which does not protect you from an attacker in the middle; it is a debugging step, not a deployment setting.
Run several statements in one query callmultiple-statements
const conn = await mysql.createConnection({
host: '127.0.0.1',
database: 'shop',
multipleStatements: true,
})
const [results] = await conn.query(
'SELECT COUNT(*) AS users FROM users; SELECT COUNT(*) AS orders FROM orders;',
)
console.log(results[0][0].users, results[1][0].orders)Off by default for a reason: with it on, any SQL injection escalates from reading one table to running arbitrary statements. The result shape also changes to an array of result sets, which breaks code that assumes rows[0] is a row. Use it for migrations and startup scripts, not for request handling.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| mariadb | npm | You are on MariaDB specifically and want the vendor driver, with its own connection pool and MariaDB-only features |
| mysql | npm | You maintain legacy code on mysqljs/mysql and cannot migrate; note the package has been effectively frozen for years, so it is a reason to move, not to start |
| drizzle-orm | npm | You want typed queries and migrations from your schema while still running mysql2 as the driver underneath |
| kysely | npm | You want a typed SQL query builder with no ORM semantics, using the mysql2 dialect |