Skip to main content

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 binary character set. It is a character set in the catalog's bookkeeping and a byte string in practice. A VARBINARY column holds bytes rather than text, and demanding utf8mb4 of it would be advice that breaks the column.
  • Which utf8mb4 collation 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_server is 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