mrkeyoor.com_
Thu 06 Aug 10:55 UTC
npmDataupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5The 3.x line has been out since January 2023 and the surface (createConnection, createPool, query, execute, escape) has not changed; it is still API compatible with mysqljs/mysql from 2013. Point releases have quietly changed behavior around type conversion and auth more than once, so pin and read release notes.
Docs4/5The documentation site has a quickstart, per-topic pages for prepared statements, pooling, the promise wrapper, authentication switching, and TypeScript, an FAQ, runnable examples, and Chinese and Brazilian Portuguese translations. What is still thin is a single exhaustive option reference; some flags are only really explained in issue threads.
Maintenance4/5Repo pushed 6 August 2026 and 3.23.2 shipped 27 July 2026, with canary builds published continuously between releases. The counterweight is 421 open issues (458 counting PRs) against a very small maintainer group, so triage lags even though shipping does not.
Ecosystem5/5Around 14.2M weekly downloads and it is the required MySQL driver for Sequelize, Knex, Drizzle, TypeORM, and Kysely, which means most MySQL-on-Node stacks depend on it transitively even when the app never imports it.

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
Skip it if

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

PackageRegistryPick it when
mariadbnpmYou are on MariaDB specifically and want the vendor driver, with its own connection pool and MariaDB-only features
mysqlnpmYou 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-ormnpmYou want typed queries and migrations from your schema while still running mysql2 as the driver underneath
kyselynpmYou want a typed SQL query builder with no ORM semantics, using the mysql2 dialect