MY.L6.CHARSET_NOT_UTF8MB4 — Text stored under something narrower than utf8mb4
- Category: idiom
- Level: 6
- Confidence: deterministic
- Downtime class: none
- Stability: stable
- Suites: audit
- Applies to: MySQL 8.4
- Not transferable to MariaDB: this page describes MySQL 8.4 behavior. MariaDB answers the same driver and does not share these semantics, so SQLens refuses it outright rather than reasoning about it — see drivers/unsupported.
utf8mb3 is the one that looks solved
latin1 announces itself — nobody believes it holds Unicode. utf8mb3 is the trap: the name says
utf8, it holds everything anyone tests with, and it stores three bytes per character. That is
exactly the Basic Multilingual Plane and not one character more. Every emoji is outside it. So is a
good deal of CJK, including characters that appear in ordinary personal names.
What MySQL does with a value it cannot represent depends on the SQL mode, and the lax case is the dangerous one: the character is replaced, or the value truncated at it, without an error. The row is stored, altered, and nobody finds out until somebody asks why their name is wrong.
MySQL 8.4 deprecates utf8mb3, which puts a deadline on top of the correctness argument.
Flagged
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->string('body')->charset('utf8mb3');
});
Preferred
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->string('body')->charset('utf8mb4')->collation('utf8mb4_0900_ai_ci');
});
Three levels, and the database one is invisible
A column inherits its character set from its table, and a table from its database. So a schema whose
tables are all utf8mb4 can look entirely healthy while the database default is still latin1
— and nothing among the existing tables reveals it. The default decides what the next table gets:
one hand-written CREATE TABLE, one raw-SQL migration, one table added by an operator, and a column
appears that silently cannot hold an emoji.
That is why the audit judges the database as its own subject rather than treating it as redundant:
ALTER DATABASE shop CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
That statement alters no existing table. It stops the spread without touching data, which makes it a safe first step — and not the whole job.
One finding per table, listing every column
A table with forty latin1 columns produces one finding naming all forty, not forty findings.
That is not brevity. A catalog finding is located at the object, and results are deduplicated by rule id and location — so forty column verdicts would arrive as one, arbitrarily chosen, with the other thirty-nine dropped without a word. A reader would convert the single column named and believe the table was finished. It is also the truer report: converting a table is one decision about one table.
Converting is a table copy
ALTER TABLE shop.orders CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
MySQL performs this as a copy of the whole table — plan it as one rather than running it at peak. The lint suite classifies the statement itself, and the downtime class lives there, on MY.L2.COPY_ALTER_CHARSET, not on this audit finding. An observation about a state carries no downtime; the operation that changes it does.
One thing to check first: a utf8mb4 index key is wider than a latin1 one, so a legacy index
already near the length limit can refuse to be rebuilt.
What is not flagged
- The
binarycharacter set. It is a character set in the catalog's bookkeeping and a byte string in practice. AVARBINARYcolumn holds bytes rather than text, and demandingutf8mb4of it would be advice that breaks the column. - Which
utf8mb4collation the object carries. That is a separate decision with separate consequences, and it has its own id — MY.L6.COLLATION_LEGACY — so the two can be ignored, baselined and fixed independently. Chained under one id, an ignore entry for the storage question would silence the sorting one as well. - The server default.
character_set_serveris a variable rather than a catalog object, with a different remedy, and it is MY.L6.CHARACTER_SET_SERVER_NOT_UTF8MB4.
A column that will only ever hold ASCII — a country code, a base64 token, a hex digest — loses nothing by being narrow, and converting it costs a table copy for no gain. Ignore those deliberately:
'audit' => [
'ignore' => [
'pairs' => [
['rule' => 'MY.L6.CHARSET_NOT_UTF8MB4', 'objects' => ['shop.legacy_imports']],
],
],
],
The finding stays counted and listed under what hid it, so the decision remains visible instead of disappearing from the report. Note that the entry hides the whole table, columns included: the finding is located at the table, so there is no finer grain to suppress.
When the encoding could not be read
A table that reports no character set at all is answered undetermined, with the reason
text_encoding_unknown — never pass. MySQL reports none for a table whose storage engine has no
concept of one, and the reader keeps an absent value absent rather than substituting a plausible
default. "Nobody could read the encoding" and "the encoding is fine" are different statements, and
only a report that keeps them apart is worth reading the one time it matters.
Sources
- MySQL 8.4 reference: the utf8mb3 character set
— three bytes per character, therefore the Basic Multilingual Plane only; deprecated in 8.4 with
utf8mb4named as the replacement. - MySQL 8.4 reference: converting between character sets
—
ALTER TABLE … CONVERT TO CHARACTER SETrewrites the column definitions and the stored values, which is why it is a table copy rather than a metadata change.