MySQL

Every type, null, row count and parameter rule on MySQL

This page is the reference behind the main docs for MySQL: the place to look when a type, a | null, a row count or a parameter surprises you. Postgres and SQLite have the same page, with every answer in the same place.

VersionsMySQL 8.0.22 or later, which types a parameter from its context; checked on MySQL 8.0, 8.4, 9.7 and 26.7
URLA mysql:// URL, in DATABASE_URL or direct_url
Type checkReads the schema through mysql2
Driversmysql2, PlanetScale and Bun.sql (Drivers)
View the schema behind the examples
create table users (
  id int auto_increment primary key,
  name varchar(100) not null,
  email varchar(255) unique,
  role enum('admin', 'member') not null default 'member',
  active boolean not null default true,
  created_at datetime(3) not null default (utc_timestamp(3))
);
create table orders (
  id bigint auto_increment primary key,
  user_id int not null,
  total decimal(12, 2) not null,
  placed_at datetime(6) not null default (utc_timestamp(6)),
  ships_on date,
  receipt blob,
  foreign key (user_id) references users (id)
);
create table events (
  id int auto_increment primary key,
  kind varchar(10) not null,
  payload json not null check (json_schema_valid('{
    "type": "object",
    "properties": {
      "x": { "type": "integer" },
      "code": { "type": "string" }
    },
    "required": ["x"]
  }', payload))
);

Data types

Reylo decodes every value of its own queries itself, on every driver, so each SQL type has one TypeScript type and one form at run time. In each pair below, the SQL is a value and the JavaScript is what Reylo returns for it: select <SQL> as v gives the row { v: <JavaScript> }.

SQL typeTypeScriptThe value .run() returns
TINYINT, SMALLINT, MEDIUMINT, INT, YEARnumber2026
BIGINT, BIGINT UNSIGNED, COUNT(*)bigint9007199254740993n
BOOLEAN (a TINYINT(1)), BIT(1)booleantrue
A comparison, IS NULL, IN, BETWEEN, LIKE, EXISTS, NOT, AND, ORbooleanfalse
DECIMAL, SUM(…), AVG(…), /string"12.50"
FLOAT, DOUBLEnumber1.1
CHAR, VARCHAR, TEXTstring"Ada"
ENUMstring"member"
SETstring"a,c"
DATEstring"2026-10-20"
DATETIME, TIMESTAMPstring"2026-10-20T10:00:00.123"
TIMEstring"10:00:00"
JSONunknown{ a: 1 }
JSON a JSON Schema CHECK holdswhat the schema admits{ x: 10 }
json_object(…), json_arrayagg(…)the object or array it builds{ total: 12.5 }
BINARY, VARBINARY, BLOB, BIT wider than 1Uint8ArrayUint8Array.of(1, 2, 255)
GEOMETRYunknownits bytes, a Uint8Array

Integers

A BIGINT, signed or unsigned, is a JavaScript bigint, and so is COUNT(*). MySQL computes most integer expressions as a BIGINT too, so id + 1, length(name), coalesce(n, 0) and lag(n) over (…) are bigint. MySQL's CAST has no 32-bit integer, so cast to double where you want a number:

import  from "./my_count_orders.sql";

const counts = await .();
const counts: {
    total: bigint;
    total_number: number;
}
select count(*) as total, cast(count(*) as double) as total_number
from orders

A type the parameter decides

An expression whose type depends on a bound value takes every type it may have: :n + 1 is bigint | number | string, a BIGINT, a DOUBLE or a DECIMAL by what you pass and how the driver sends it.

SQL
CAST(1 AS SIGNED) + 1
JavaScript
2n
SQL
CAST(1.5 AS DOUBLE) + 1
JavaScript
2.5
SQL
CAST(1.5 AS DECIMAL(2,1)) + 1
JavaScript
"2.5"

A YEAR

A YEAR is a number.

SQL
CAST(2026 AS YEAR)
JavaScript
2026

Writing a BIGINT out

JavaScript's JSON.stringify refuses a bigint, so write one out of your API as its text, String(v), or as the integer itself, JSON.rawJSON(String(v)), which is what .outputSchema describes.

SQL
CAST(9007199254740993 AS SIGNED)
JavaScript
9007199254740993n
SQL
CAST(18446744073709551615 AS UNSIGNED)
JavaScript
18446744073709551615n

Inside JSON the statement builds

A BIGINT is a JSON number, which JSON.parse rounds past 2^53 (JSON).

SQL
json_object('n', CAST(9007199254740993 AS SIGNED))
JavaScript
{ n: 9007199254740992 }

Decimals

A DECIMAL is its exact text, "12.50", with the column's scale. So are SUM(…) of any column, AVG(…) and a division, which MySQL computes as DECIMAL: sum(total) is "19.75", and id / 2 is "0.5000".

A FLOAT

A FLOAT is a 32-bit float. On mysql2, which reads the binary protocol, it is the float's exact value, as on Bun.sql in a statement with parameters. PlanetScale, and Bun.sql in a statement without them, read the text protocol and give the shorter text, 1.1. A DOUBLE is the same on every driver.

SQL
CAST(1.1 AS FLOAT)
JavaScript
1.100000023841858
SQL
CAST(1.1 AS DOUBLE)
JavaScript
1.1

Inside JSON the statement builds

A DECIMAL is a JSON number, which JSON.parse reads as a double.

SQL
json_object('d', CAST(12345678901234.123456 AS DECIMAL(20,6)))
JavaScript
{ d: 12345678901234.123 }

A DECIMAL parameter

A DECIMAL parameter takes the text, "12.50" (What a parameter takes).

Dates and times

No value is a Date. Each is the text MySQL writes, with a T between the date and the time of a DATETIME or a TIMESTAMP, and as many fraction digits as the column keeps:

import  from "./my_order_dates.sql";

const order = await .(, { : 1 });
const order: {
    placed_at: string;
    ships_on: string | null;
} | null
select placed_at, ships_on from orders where id = :id

Where your runtime has Temporal, Temporal.PlainDateTime.from() reads the time.

Fractions of a second

The fraction has as many digits as the type keeps: a DATETIME(3) keeps three even when they are zero, and a DATETIME none.

SQL
CAST('2026-10-20 10:00:00.123456' AS DATETIME(6))
JavaScript
"2026-10-20T10:00:00.123456"
SQL
CAST('2026-10-20 10:00:00' AS DATETIME(3))
JavaScript
"2026-10-20T10:00:00.000"
SQL
CAST('2026-10-20 10:00:00' AS DATETIME)
JavaScript
"2026-10-20T10:00:00"

A TIMESTAMP's zone

A TIMESTAMP stores an instant, and MySQL writes it in the session's time_zone, the server's own unless the connection sets another. Reylo cannot know that zone, so the text has none. Run set time_zone = '+00:00' on the connection to read UTC, or keep UTC in a DATETIME.

An offset in the text

MySQL keeps no zone in a DATETIME or a TIMESTAMP: it drops a Z from the text it reads, with a warning, and moves a +00:00 into the session's zone, here -07:00. Keep UTC in a DATETIME written by utc_timestamp(), as the schema above does.

SQL
CAST('2026-10-20 10:00:00Z' AS DATETIME)
JavaScript
"2026-10-20T10:00:00"
SQL
CAST('2026-10-20 10:00:00+00:00' AS DATETIME)
JavaScript
"2026-10-20T03:00:00"

A TIME

A TIME is its text, with the fraction its type keeps.

SQL
CAST('10:00:00' AS TIME)
JavaScript
"10:00:00"
SQL
CAST('10:00:00.5' AS TIME(1))
JavaScript
"10:00:00.5"

Inside JSON the statement builds

A DATETIME is MySQL's text, with a space and six fraction digits.

SQL
json_object('at', CAST('2026-10-20 10:00:00' AS DATETIME))
JavaScript
{ at: "2026-10-20 10:00:00.000000" }

On Bun.sql

A DATETIME keeps its milliseconds, not its microseconds, and drops a fraction that is all zeros. In a statement with parameters, a TIME drops its fraction too (Drivers).

SQL
CAST('2026-10-20 10:00:00.123456' AS DATETIME(6))
JavaScript
"2026-10-20T10:00:00.123"
SQL
CAST('2026-10-20 10:00:00' AS DATETIME(3))
JavaScript
"2026-10-20T10:00:00"

Truth values

MySQL has no boolean type: BOOLEAN is a TINYINT(1), and a comparison is the integer 1 or 0. Reylo makes a table's TINYINT(1) and BIT(1) columns booleans, and every truth value the statement computes:

import  from "./my_user_flags.sql";

const rows = await .();
const rows: {
    id: number;
    active: boolean;
    is_admin: boolean;
}[]
select id, active, role = 'admin' as is_admin from users

A table's column

A table's column is a boolean read straight from the table, or through a derived table or a with query, which MySQL traces to the table.

A truth value the statement computes

A truth value the statement computes is a comparison (=, <>, <, <=, >, >=, <=>), IS [NOT] NULL, IS [NOT] TRUE, IS [NOT] FALSE, IN, BETWEEN, LIKE, REGEXP, EXISTS, NOT, AND, OR, XOR, TRUE, FALSE, and a CASE, IF, COALESCE, IFNULL, NULLIF or scalar subquery whose every value is one.

SQL
1 = 1
JavaScript
true
SQL
'Ada' LIKE 'B%'
JavaScript
false
SQL
IF(1 = 1, TRUE, FALSE)
JavaScript
true
SQL
(SELECT 1 = 1)
JavaScript
true

Where MySQL hides the column

A number stays a number where MySQL hides the column: a view's column, an aggregate or arithmetic over a flag (max(active), active + 0), and a truth value a derived table computes and the outer statement reads.

SQL
TRUE + 0
JavaScript
1n
SQL
max(1 = 1)
JavaScript
1n
SQL
(SELECT b FROM (SELECT 1 = 1 AS b) d)
JavaScript
1n

Values other than 0 and 1

A TINYINT(1) holds any number from −128 to 127. Reylo reads 0 and 1 as false and true, and refuses any other value, naming its column (Values Reylo refuses).

Inside JSON the statement builds

A flag column is the number MySQL writes, 1, and a comparison is true. Where MySQL may write a truth value as 1, beside an aggregate or from a subquery, its type takes the number too.

SQL
json_object('b', 1 = 1)
JavaScript
{ b: true }
SQL
json_object('b', max(1 = 1))
JavaScript
{ b: 1 }

JSON

A JSON value is parsed, and typed unknown, unless a JSON_SCHEMA_VALID CHECK holds the column and the statement reads it straight from its table, or the statement builds the JSON:

import  from "./my_events.sql";

const rows = await .();
const rows: {
    kind: string;
    payload: {
        x: number;
        code?: string;
        [key: string]: unknown;
    };
}[]
select kind, payload from events

JSON the statement builds with json_object and json_arrayagg is typed from their arguments, each value as MySQL writes it in JSON:

import  from "./my_order_summaries.sql";

const rows = await .();
const rows: {
    name: string;
    summary: {
        total: number;
        placed_at: string;
    };
}[]
select u.name,
  json_object('total', o.total, 'placed_at', o.placed_at) as summary
from orders o
join users u on u.id = o.user_id

Through json_arrayagg the schema's type holds:

import  from "./my_events_by_kind.sql";

const groups = await .();
const groups: {
    kind: string;
    payloads: {
        x: number;
        code?: string;
        [key: string]: unknown;
    }[];
}[]
select kind, json_arrayagg(payload) as payloads
from events
group by kind

Where the schema's type reaches

The schema's type reaches a plain select of the column over tables named without a schema, and the column inside json_object or json_arrayagg.

Where the column is unknown

Through a derived table or a with query, under a union, or in an expression (json_extract(payload, '$.x')), the column is unknown, and so is a parameter written to it.

Draft 4

MySQL's validator reads every schema as draft 4, whatever its $schema says, so const, if, prefixItems and contains narrow nothing.

Values inside a document

Inside a document a value is what JSON writes: integers and DECIMAL are numbers, which JSON.parse rounds past 2^53 on every driver, and a date is MySQL's text.

SQL
CAST('{"n": 9007199254740993}' AS JSON)
JavaScript
{ n: 9007199254740992 }
SQL
json_object('d', CAST('2026-10-20' AS DATE))
JavaScript
{ d: "2026-10-20" }

Keys

MySQL stores a document with its keys sorted, and a key given twice holds its last value.

SQL
CAST('{"b": 1, "a": 2, "b": 3}' AS JSON)
JavaScript
{ a: 2, b: 3 }

An empty array as the fallback

A coalesce(json_arrayagg(…), json_array()) keeps the array's type, as does any fallback that is an empty array.

A JSON parameter

A JSON parameter takes any JSON value, and a string is stored as a JSON string (What a parameter takes).

Binary

A BINARY, a VARBINARY, every BLOB and a BIT wider than 1 are a Uint8Array. The mysql2 driver gives Reylo a Buffer, which is one, so .run() returns it as it is.

A GEOMETRY

A GEOMETRY is unknown, and its value is MySQL's bytes, the SRID and the WKB, as a Uint8Array.

SQL
ST_GeomFromText('POINT(1 2)')
JavaScript
<Buffer 00 00 00 00 01 01 00 00 00 00 00 00 00 00 00 f0 3f 00 00 00 00 00 00 00 40>

A binary parameter

A binary parameter takes a Uint8Array, a Buffer or not, on every driver.

ENUM and SET

An ENUM is a string, its label, and a SET is a string of its members joined by commas, "a,c", on every driver.

A value that is no label

Compared with a value that is no label, an ENUM matches nothing, and an insert of one is MySQL's error in strict mode.

Values Reylo refuses

Each driver hands Reylo exact integers and dates as text, so one value is refused, with an error that names its column:

A truth value other than 0 or 1

A TINYINT(1) column that Reylo types boolean and that holds 2 is refused, rather than read as true. Store 0 or 1 in it, or read the stored number with active + 0.

Nullability

A column is | null unless Reylo infers it never is. On MySQL that starts from the server: it marks each result column NOT NULL or not, and Reylo reads that mark and adds what the statement's where and group by rule out.

  • A table's column is non-null where the table says NOT NULL, exactly.
  • An outer join makes the joined table's columns nullable, unless a test in the where that fails on NULL shows the joined row exists, as where p.published_at is not null or where p.id > 0 does, or a later inner join's on does.
  • An expression is non-null where the server says so: coalesce and ifnull with a non-null argument, count, length, arithmetic over non-null integers, comparisons.
    • The server is cautious with string and date functions, most casts and division: upper(name), concat(name, '!'), date(created_at), cast(id as char) and id / 2 are | null even when their input is NOT NULL.
  • A test in the where that fails on NULL makes the column it tests non-null: where email is not null, where email like '%@%', where lower(email) = :email. A test under an or whose other arm tests another column counts for nothing, nor do coalesce, ifnull and <=>.
  • The json_arrayagg aggregate is NULL over no rows, and never under a group by, where every group has a row.
  • A WITH ROLLUP adds a row per level whose grouped columns are NULL, and the type says so.
  • A correlated subquery in the select list makes the server clear NOT NULL on the outer table's columns. Reylo asks again without the subquery and restores it.

A test in the where shows the joined row exists, so its columns are non-null:

import  from "./my_shipped.sql";

const rows = await .();
const rows: {
    name: string;
    total: string;
    ships_on: string;
}[]
select u.name, o.total, o.ships_on
from users u
left join orders o on o.user_id = u.id
where o.ships_on is not null

Where the server cannot see what you know, coalesce and ifnull give a default, and the column is non-null:

import  from "./my_shouts.sql";

const rows = await .();
const rows: {
    shout: string | null;
    shout_or_empty: string;
}[]
select upper(name) as shout, coalesce(upper(name), '') as shout_or_empty
from users

Row counts

How many rows .run() resolves to comes from what Reylo infers the statement can return:

Resolves tocardinalityOn MySQL, when the statement
Rowoneis an aggregate without a group by, or values row(…) of one row
Row | nullmaybeOnematches every column of one table's primary key or unique key with =, against a value of the key's own type; or has limit 1
voidnoneis an insert, update, delete or replace, on duplicate key update included
Row[]manyis anything else

MySQL has no returning, so a write resolves to nothing. Read a new id with last_insert_id(), on the same connection, since it is that connection's:

import  from "mysql2/promise";
import  from "./my_last_insert_id.sql";

const  = .(..);
const  = await .();
// … run an insert on conn, then:
const { id } = await .();
const id: bigint
.();
select last_insert_id() as id
  • A joined table's key counts for nothing: posts join users on users.id = posts.user_id where posts.id = :id is Row[].
  • A distinct or group by over values the where pins counts for nothing unless they are the key.
  • A string key compared with a number counts for nothing, since MySQL compares the two as doubles: where email = 5 is Row[].
  • A TIMESTAMP key counts for nothing: where the clocks go back an hour, two instants show as one time.
  • A WITH ROLLUP adds rows, and a with query named like a table is not the table, so neither has a key.
  • A statement the server reads otherwise than its text, under the session's sql_mode, counts for nothing (How the text is read).
  • A multi-table delete or update resolves to nothing, and so does a select … from … into @variable.
  • At run time a Row statement throws unless it gets exactly one row, and a Row | null statement throws when it gets more than one.

Parameters

Placeholders

Write each parameter as :name. Reylo sends ? and .run() takes an object keyed by name. A statement may use MySQL's own ? instead, never beside :name, and the object's keys are then the numbers, { 1: 42 }.

  • Each parameter appears once in a statement, so that one place types it. To use a value twice, select it once in a with and read it from there.
  • An @name is a MySQL user variable, never a parameter.

What a parameter takes

Every value .run() returns goes back in as a parameter of its type, on every driver, so a row can be passed straight to the next statement; a null goes back in where the parameter takes one (Parameters that may be null):

SQL typeA parameter takes
TINYINT, SMALLINT, MEDIUMINT, INT, YEARnumber
A BIGINT columnnumber, bigint or a string of digits
A TINYINT(1) or BIT(1) columnboolean or number
DECIMALstring
FLOAT, DOUBLEnumber
CHAR, VARCHAR, TEXT, ENUM, SETstring
DATE, DATETIME, TIMESTAMP, TIMEstring
JSONany JSON value
BINARY, VARBINARY, BLOBUint8Array
  • MySQL describes every integer parameter as a BIGINT, and every BIT one alike. So where the statement names the column, Reylo types a parameter by the column it is compared with or written to:
    • in a comparison such as col = :p, an in or a between;
    • in an insert's values and an update's set.
  • Elsewhere Reylo takes what MySQL says: where id = :id on an INT column, and limit :n, take a number.
  • Reylo's MySQL drivers bind a true as 1.
  • A DATE, DATETIME or TIMESTAMP parameter takes the wall-clock text, "2026-10-12 19:06:48", and refuses a Date: drivers write a Date's time in different zones, the app's, the server's or UTC.
  • A JSON parameter reaches the database as its JSON text on every driver, so a string is a JSON string: '{"a":1}' is stored as the string "{\"a\":1}".
  • A typed JSON parameter, :events::json('…'), reaches MySQL as cast(? as json) holding its JSON text, after .run() has filled in its schema's defaults and checked the value against the schema.

Parameters without a type

MySQL types every parameter from where the statement uses it, since 8.0.22. One that nothing else types, such as a bare select :x, is a string.

Parameters that may be null

A parameter takes null in two places:

  • as the first argument of coalesce or ifnull, with a cast or without;
  • written whole, with no cast, to a column that may hold NULL (values, insert … set, update … set, on duplicate key update, replace), unless it also fills a NOT NULL column.

A cast is the parameter's whole type, so :email::text never takes null, and a test for null (:p is null, isnull(:p), :p <=> col) makes nothing nullable. A column holds NULL unless it is NOT NULL or a check (… is not null) holds it:

import  from "./my_users_by_role.sql";

const  = await .(, { role: null });
role: string | null
select id, name from users
where role = coalesce(:role::text, role)

Casts

MySQL has no ::, so Reylo writes :p::type as MySQL's CAST, and checks the value against the type before the statement runs, since MySQL's CAST turns a value it cannot read into 0 or NULL instead of failing. MySQL's own cast(:p as …) works too.

You writeMySQL runs
::int, ::bigint, ::smallintCAST(? AS SIGNED)
::int unsignedCAST(? AS UNSIGNED)
::numeric(10,2), ::decimal(10,2)CAST(? AS DECIMAL(10,2))
::real, ::double precisionCAST(? AS FLOAT), CAST(? AS DOUBLE)
::text, ::varchar(20), ::uuidCAST(? AS CHAR), CAST(? AS CHAR(20)), CAST(? AS CHAR(36))
::date, ::time, ::datetimeCAST(? AS DATE), CAST(? AS TIME), CAST(? AS DATETIME(6))
::jsonCAST(? AS JSON)
::bytea, ::blobCAST(? AS BINARY)
  • The check refuses an integer out of the type's range, a decimal with more digits than its scale keeps, and a date that does not exist ("2026-02-30"), before the statement runs.
  • In limit and offset, which take only a bare value on MySQL, :n::int is written as the bare ?, and a cast to another type is an error.
  • A cast to ::timestamptz or ::boolean is an error: MySQL has no type for an instant with its zone, and no boolean to cast to.

How the text is read

MySQL reads its text its own way, and Reylo reads it the same way:

  • A backslash in a string escapes the character after it, in single and double quotes: 'it\'s :not' holds no placeholder.
  • A # starts a comment to the end of the line, and so does -- with a space after it: :a --:b is :a - (-:b). A block comment ends at its first */.
  • Where NO_BACKSLASH_ESCAPES or ANSI_QUOTES would read a statement otherwise, Reylo asks for the session's sql_mode and reads the statement by it.
  • A || is OR unless the session sets PIPES_AS_CONCAT: name || 'x' is a number. Write concat(name, 'x').
  • A built-in's name with a space before its parenthesis, count (x), is a stored function's unless the session sets IGNORE_SPACE, as mysql2 does.
  • Where the session reads the statement otherwise than its text, Reylo keeps the server's types and infers no cardinality and no JSON shape.
  • A /*! … */ comment is SQL MySQL runs, so a statement with one is an error: write it as plain SQL.

Drivers

Each driver reads MySQL's protocol as it always does, and Reylo makes only the choice per type that follows, for its own query, on the client you pass. A parser or option you set for your own queries does not reach Reylo's, and none of Reylo's reaches yours.

DriverHow Reylo reads its valuesRefusedLost before Reylo sees it
mysql2Its .execute(), with Reylo's options with each query, so a BIGINT and a date come as their exact text
PlanetScaleReylo's cast with each query2.0, which binds no parameters
Bun.sqlBun's own values, convertedA DATETIME's microseconds, an all-zero fraction, and, in a statement with parameters, a TIME's fraction
  • The mysql2 driver runs on .execute(), the binary protocol, and its callback API through .promise(), both on the app's own pool or connection.
  • On Bun.sql, an expression that may be a BIGINT or a DECIMAL by what is bound (round(:x)) reads digits without a point as a BIGINT, since Bun gives both as text.
  • Each driver takes its transactions and connections too: mysql2's promise or callback API, PlanetScale's .transaction(), and Bun.sql's .begin(), .savepoint() and .reserve().

The type check reads the schema through mysql2, so an app on PlanetScale or Bun.sql adds mysql2 as a dev dependency.

Limits

The edges of what Reylo types on MySQL, each with what to write instead:

  • What a write returns. MySQL has no returning, so a write resolves to nothing: read a new id with select last_insert_id() on the same connection, and a changed row with a select after it.
  • An ENUM's labels. An ENUM is a string (ENUM and SET).
  • Where an error is. A MySQL prepare error has no position, so the type check's error covers the whole statement.
  • A statement Reylo's parser does not read. Reylo reads the statement itself for row counts and JSON shapes. One it cannot read keeps the server's types and nullability, resolves to Row[], and its JSON is unknown.
  • A with before an insert. MySQL 8.4 refuses with … insert, so a with follows insert into … (…), as in the main docs' JSON parameters.
  • A parameter in place of a name. A parameter is a value: select * from :table is MySQL's syntax error, and order by :column sorts by a constant. Choose a column with case :sort when 'name' then name end.
  • A statement with more than 500 operators (a + b + …, x = 1 or x = 2 or …), or nested past 200 levels (196 nested function calls, 195 nested cases or 97 nested subqueries), which Reylo types from the server's description alone: any number of rows.
  • One statement per file. A file holds one select, insert, update, delete, replace, values or table. A call is an error in the type check, and so is a file of several statements or of DDL, such as a migration.
Released under the MIT License. Copyright © 2026 Colin McDonnell.

On this page

GitHub