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.
| Versions | MySQL 8.0.22 or later, which types a parameter from its context; checked on MySQL 8.0, 8.4, 9.7 and 26.7 |
| URL | A mysql:// URL, in DATABASE_URL or direct_url |
| Type check | Reads the schema through mysql2 |
| Drivers | mysql2, 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 type | TypeScript | The value .run() returns |
|---|---|---|
TINYINT, SMALLINT, MEDIUMINT, INT, YEAR | number | 2026 |
BIGINT, BIGINT UNSIGNED, COUNT(*) | bigint | 9007199254740993n |
BOOLEAN (a TINYINT(1)), BIT(1) | boolean | true |
A comparison, IS NULL, IN, BETWEEN, LIKE, EXISTS, NOT, AND, OR | boolean | false |
DECIMAL, SUM(…), AVG(…), / | string | "12.50" |
FLOAT, DOUBLE | number | 1.1 |
CHAR, VARCHAR, TEXT | string | "Ada" |
ENUM | string | "member" |
SET | string | "a,c" |
DATE | string | "2026-10-20" |
DATETIME, TIMESTAMP | string | "2026-10-20T10:00:00.123" |
TIME | string | "10:00:00" |
JSON | unknown | { a: 1 } |
JSON a JSON Schema CHECK holds | what the schema admits | { x: 10 } |
json_object(…), json_arrayagg(…) | the object or array it builds | { total: 12.5 } |
BINARY, VARBINARY, BLOB, BIT wider than 1 | Uint8Array | Uint8Array.of(1, 2, 255) |
GEOMETRY | unknown | its 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 .();select count(*) as total, cast(count(*) as double) as total_number
from ordersA 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.
CAST(1 AS SIGNED) + 12nCAST(1.5 AS DOUBLE) + 12.5CAST(1.5 AS DECIMAL(2,1)) + 1"2.5"A YEAR
A YEAR is a number.
CAST(2026 AS YEAR)2026Writing 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.
CAST(9007199254740993 AS SIGNED)9007199254740993nCAST(18446744073709551615 AS UNSIGNED)18446744073709551615nInside JSON the statement builds
A BIGINT is a JSON number, which JSON.parse rounds past 2^53 (JSON).
json_object('n', CAST(9007199254740993 AS SIGNED)){ 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.
CAST(1.1 AS FLOAT)1.100000023841858CAST(1.1 AS DOUBLE)1.1Inside JSON the statement builds
A DECIMAL is a JSON number, which JSON.parse reads as a double.
json_object('d', CAST(12345678901234.123456 AS DECIMAL(20,6))){ 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 });select placed_at, ships_on from orders where id = :idWhere 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.
CAST('2026-10-20 10:00:00.123456' AS DATETIME(6))"2026-10-20T10:00:00.123456"CAST('2026-10-20 10:00:00' AS DATETIME(3))"2026-10-20T10:00:00.000"CAST('2026-10-20 10:00:00' AS DATETIME)"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.
CAST('2026-10-20 10:00:00Z' AS DATETIME)"2026-10-20T10:00:00"CAST('2026-10-20 10:00:00+00:00' AS DATETIME)"2026-10-20T03:00:00"A TIME
A TIME is its text, with the fraction its type keeps.
CAST('10:00:00' AS TIME)"10:00:00"CAST('10:00:00.5' AS TIME(1))"10:00:00.5"Inside JSON the statement builds
A DATETIME is MySQL's text, with a space and six fraction digits.
json_object('at', CAST('2026-10-20 10:00:00' AS DATETIME)){ 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).
CAST('2026-10-20 10:00:00.123456' AS DATETIME(6))"2026-10-20T10:00:00.123"CAST('2026-10-20 10:00:00' AS DATETIME(3))"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 .();select id, active, role = 'admin' as is_admin from usersA 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.
1 = 1true'Ada' LIKE 'B%'falseIF(1 = 1, TRUE, FALSE)true(SELECT 1 = 1)trueWhere 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.
TRUE + 01nmax(1 = 1)1n(SELECT b FROM (SELECT 1 = 1 AS b) d)1nValues 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.
json_object('b', 1 = 1){ b: true }json_object('b', max(1 = 1)){ 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 .();select kind, payload from eventsJSON 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 .();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_idThrough json_arrayagg the schema's type holds:
import from "./my_events_by_kind.sql";
const groups = await .();select kind, json_arrayagg(payload) as payloads
from events
group by kindWhere 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.
CAST('{"n": 9007199254740993}' AS JSON){ n: 9007199254740992 }json_object('d', CAST('2026-10-20' AS DATE)){ d: "2026-10-20" }Keys
MySQL stores a document with its keys sorted, and a key given twice holds its last value.
CAST('{"b": 1, "a": 2, "b": 3}' AS JSON){ 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.
ST_GeomFromText('POINT(1 2)')<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
wherethat fails onNULLshows the joined row exists, aswhere p.published_at is not nullorwhere p.id > 0does, or a later inner join'sondoes. - An expression is non-null where the server says so:
coalesceandifnullwith 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)andid / 2are| nulleven when their input isNOT NULL.
- The server is cautious with string and date functions, most casts and division:
- A test in the
wherethat fails onNULLmakes the column it tests non-null:where email is not null,where email like '%@%',where lower(email) = :email. A test under anorwhose other arm tests another column counts for nothing, nor docoalesce,ifnulland<=>. - The
json_arrayaggaggregate isNULLover no rows, and never under agroup by, where every group has a row. - A
WITH ROLLUPadds a row per level whose grouped columns areNULL, and the type says so. - A correlated subquery in the select list makes the server clear
NOT NULLon 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 .();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 nullWhere 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 .();select upper(name) as shout, coalesce(upper(name), '') as shout_or_empty
from usersRow counts
How many rows .run() resolves to comes from what Reylo infers the statement can return:
| Resolves to | cardinality | On MySQL, when the statement |
|---|---|---|
Row | one | is an aggregate without a group by, or values row(…) of one row |
Row | null | maybeOne | matches 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 |
void | none | is an insert, update, delete or replace, on duplicate key update included |
Row[] | many | is 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 .();.();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 = :idisRow[]. - A
distinctorgroup byover values thewherepins 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 = 5isRow[]. - A
TIMESTAMPkey counts for nothing: where the clocks go back an hour, two instants show as one time. - A
WITH ROLLUPadds rows, and awithquery 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
deleteorupdateresolves to nothing, and so does aselect … from … into @variable. - At run time a
Rowstatement throws unless it gets exactly one row, and aRow | nullstatement 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
withand read it from there. - An
@nameis 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 type | A parameter takes |
|---|---|
TINYINT, SMALLINT, MEDIUMINT, INT, YEAR | number |
A BIGINT column | number, bigint or a string of digits |
A TINYINT(1) or BIT(1) column | boolean or number |
DECIMAL | string |
FLOAT, DOUBLE | number |
CHAR, VARCHAR, TEXT, ENUM, SET | string |
DATE, DATETIME, TIMESTAMP, TIME | string |
JSON | any JSON value |
BINARY, VARBINARY, BLOB | Uint8Array |
- MySQL describes every integer parameter as a
BIGINT, and everyBITone 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, aninor abetween; - in an
insert's values and anupdate'sset.
- in a comparison such as
- Elsewhere Reylo takes what MySQL says:
where id = :idon anINTcolumn, andlimit :n, take anumber. - Reylo's MySQL drivers bind a
trueas 1. - A
DATE,DATETIMEorTIMESTAMPparameter takes the wall-clock text,"2026-10-12 19:06:48", and refuses aDate: drivers write aDate'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 ascast(? 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
coalesceorifnull, 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 aNOT NULLcolumn.
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 });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 write | MySQL runs |
|---|---|
::int, ::bigint, ::smallint | CAST(? AS SIGNED) |
::int unsigned | CAST(? AS UNSIGNED) |
::numeric(10,2), ::decimal(10,2) | CAST(? AS DECIMAL(10,2)) |
::real, ::double precision | CAST(? AS FLOAT), CAST(? AS DOUBLE) |
::text, ::varchar(20), ::uuid | CAST(? AS CHAR), CAST(? AS CHAR(20)), CAST(? AS CHAR(36)) |
::date, ::time, ::datetime | CAST(? AS DATE), CAST(? AS TIME), CAST(? AS DATETIME(6)) |
::json | CAST(? AS JSON) |
::bytea, ::blob | CAST(? 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
limitandoffset, which take only a bare value on MySQL,:n::intis written as the bare?, and a cast to another type is an error. - A cast to
::timestamptzor::booleanis 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 --:bis:a - (-:b). A block comment ends at its first*/. - Where
NO_BACKSLASH_ESCAPESorANSI_QUOTESwould read a statement otherwise, Reylo asks for the session'ssql_modeand reads the statement by it. - A
||isORunless the session setsPIPES_AS_CONCAT:name || 'x'is a number. Writeconcat(name, 'x'). - A built-in's name with a space before its parenthesis,
count (x), is a stored function's unless the session setsIGNORE_SPACE, asmysql2does. - 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.
| Driver | How Reylo reads its values | Refused | Lost before Reylo sees it |
|---|---|---|---|
mysql2 | Its .execute(), with Reylo's options with each query, so a BIGINT and a date come as their exact text | ||
| PlanetScale | Reylo's cast with each query | 2.0, which binds no parameters | |
Bun.sql | Bun's own values, converted | A DATETIME's microseconds, an all-zero fraction, and, in a statement with parameters, a TIME's fraction |
- The
mysql2driver 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 aBIGINTor aDECIMALby what is bound (round(:x)) reads digits without a point as aBIGINT, since Bun gives both as text. - Each driver takes its transactions and connections too:
mysql2's promise or callback API, PlanetScale's.transaction(), andBun.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 withselect last_insert_id()on the same connection, and a changed row with aselectafter it. - An
ENUM's labels. AnENUMis astring(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 isunknown. - A
withbefore aninsert. MySQL 8.4 refuseswith … insert, so awithfollowsinsert into … (…), as in the main docs' JSON parameters. - A parameter in place of a name. A parameter is a value:
select * from :tableis MySQL's syntax error, andorder by :columnsorts by a constant. Choose a column withcase :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 nestedcases 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,valuesortable. Acallis an error in the type check, and so is a file of several statements or of DDL, such as a migration.